@ayepi/mock 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Philip Diffenderfer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # @ayepi/mock
2
+
3
+ Generate **schema-valid fake data** from an [`@ayepi/core`](../core) spec. Turn any
4
+ `spec(...)` into a real server whose endpoints return generated bodies that parse against
5
+ their own response schemas — great for frontend dev, demos, and tests before the backend
6
+ exists.
7
+
8
+ - **Deterministic by default** — the PRNG is seeded from `seed + endpoint + JSON(request)`,
9
+ so identical inputs yield identical output and pagination is naturally stable.
10
+ - **Generic over zod** — deeply walks objects, arrays, unions, enums, string formats, and
11
+ wrappers; no per-endpoint fixtures.
12
+ - **Pagination** — array sizes follow a `limit`-style query key (configurable), clamped to
13
+ schema length bounds.
14
+ - **Overrides** — pin values by field name/path or by zod string format.
15
+
16
+ ```sh
17
+ pnpm add -D @ayepi/mock # peers: @ayepi/core, zod
18
+ ```
19
+
20
+ ```ts
21
+ import { mockServer, generate } from '@ayepi/mock'
22
+ import { z } from 'zod'
23
+
24
+ // one-off value from any schema
25
+ generate(z.object({ id: z.uuid(), name: z.string() }))
26
+
27
+ // a real server with generated, schema-valid responses
28
+ const app = mockServer(api, { seed: 1, arraySize: 5 })
29
+ const res = await app.fetch(new Request('http://x/listUsers?limit=10', { method: 'POST' }))
30
+ ```
31
+
32
+ ## Exports
33
+
34
+ - `generate(schema, opts?, ctx?)` — one fake value from a zod schema.
35
+ - `mockHandlers(spec, opts?)` — a handler bag for `server(spec, [implement(spec).handlers(bag)])`.
36
+ - `mockServer(spec, opts?)` — a real ayepi `Server` with generated responses.
37
+
38
+ See [`ayepi-mock.md`](./ayepi-mock.md) for the full reference (options, seeding,
39
+ determinism, pagination, overrides, supported zod types, and gotchas).
40
+
41
+ ## For AI coding agents
42
+
43
+ This package ships dense, machine-oriented reference docs written for **AI coding agents**
44
+ (Claude Code, Cursor, and the like) to understand and drive the package — point your agent at them:
45
+
46
+ - [`ayepi-mock.md`](./ayepi-mock.md)
47
+
48
+ They live next to the source in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/mock) and are **not** shipped in the npm tarball.
49
+
package/dist/index.cjs ADDED
@@ -0,0 +1,448 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _ayepi_core = require("@ayepi/core");
3
+ //#region src/rng.ts
4
+ /**
5
+ * # Seeded PRNG
6
+ *
7
+ * A tiny, dependency-free deterministic random number generator. {@link fnv1a}
8
+ * folds an arbitrary string into a 32-bit seed; {@link mulberry32} turns that
9
+ * seed into a `() => number` stream of floats in `[0, 1)`. Given the same seed
10
+ * the stream is byte-for-byte reproducible, which is what makes deterministic
11
+ * mocking — and therefore stable pagination — possible.
12
+ *
13
+ * @module
14
+ */
15
+ /** FNV-1a offset basis (32-bit). */
16
+ const FNV_OFFSET_BASIS = 2166136261;
17
+ /** FNV-1a prime (32-bit). */
18
+ const FNV_PRIME = 16777619;
19
+ /** Mask to keep arithmetic within 32 unsigned bits. */
20
+ const U32_MASK = 4294967295;
21
+ /** mulberry32 mixing constant added to the running state each step. */
22
+ const MULBERRY_INCREMENT = 1831565813;
23
+ /** mulberry32 second-round multiply constant. */
24
+ const MULBERRY_ROUND2 = 3266489909;
25
+ /** Divisor that maps a 32-bit unsigned integer into `[0, 1)`. */
26
+ const U32_DIVISOR = 4294967296;
27
+ /**
28
+ * Hash a string into a 32-bit unsigned integer with FNV-1a.
29
+ *
30
+ * Used to fold a composite seed (`baseSeed + endpoint + JSON(request)`) down to a
31
+ * single integer the PRNG can be initialized from.
32
+ */
33
+ function fnv1a(input) {
34
+ let hash = FNV_OFFSET_BASIS;
35
+ for (let i = 0; i < input.length; i++) {
36
+ hash ^= input.charCodeAt(i);
37
+ hash = Math.imul(hash, FNV_PRIME);
38
+ }
39
+ return hash >>> 0;
40
+ }
41
+ /**
42
+ * Build a deterministic `() => number` PRNG (floats in `[0, 1)`) from a 32-bit
43
+ * seed using the mulberry32 algorithm. Identical seeds yield identical streams.
44
+ */
45
+ function mulberry32(seed) {
46
+ let state = seed >>> 0;
47
+ return () => {
48
+ state = state + MULBERRY_INCREMENT & U32_MASK;
49
+ let t = state;
50
+ t = Math.imul(t ^ t >>> 15, t | 1);
51
+ t ^= t + Math.imul(t ^ t >>> 7, t | MULBERRY_ROUND2);
52
+ t = (t ^ t >>> 14) >>> 0;
53
+ return t / U32_DIVISOR;
54
+ };
55
+ }
56
+ /**
57
+ * Convenience: hash a string seed and return a fresh PRNG stream for it.
58
+ *
59
+ * @param parts - composite seed parts joined into the hash input.
60
+ */
61
+ function rngFromParts(...parts) {
62
+ return mulberry32(fnv1a(parts.join("\0")));
63
+ }
64
+ /** Default query keys whose value sizes generated arrays. */
65
+ const DEFAULT_LIMIT_KEYS = [
66
+ "limit",
67
+ "pageSize",
68
+ "count"
69
+ ];
70
+ /** Default base seed when none is supplied. */
71
+ const DEFAULT_SEED = "0";
72
+ /** Fallback string length used when a format/length constraint gives no better hint. */
73
+ const WORD_LENGTH = 8;
74
+ /** Default integer ceiling (exclusive) for unconstrained number generation. */
75
+ const INT_RANGE = 1e3;
76
+ /** Decimal places kept for unconstrained floats. */
77
+ const FLOAT_PRECISION = 1e3;
78
+ /** Probability an optional/nullable value is *present* (non-undefined / non-null). */
79
+ const PRESENCE_PROB = .7;
80
+ /** Record entry count when generating from a `z.record`. */
81
+ const RECORD_SIZE = 2;
82
+ /** Bound used for bigint generation when unconstrained. */
83
+ const BIGINT_RANGE = 1e3;
84
+ /** Number `format` values that require an integer result. */
85
+ const INT_FORMATS = new Set([
86
+ "safeint",
87
+ "int32",
88
+ "uint32"
89
+ ]);
90
+ /** Characters drawn from when padding/building plain strings. */
91
+ const ALPHABET = "abcdefghijklmnopqrstuvwxyz";
92
+ /** Hex characters for uuid/id-shaped formats. */
93
+ const HEX = "0123456789abcdef";
94
+ /** Read a schema's internal `_zod.def`. */
95
+ function def(schema) {
96
+ return schema._zod.def;
97
+ }
98
+ /** Read the (possibly empty) list of check descriptors on a schema. */
99
+ function checks(d) {
100
+ return (d.checks ?? []).map((c) => c._zod.def);
101
+ }
102
+ /** Pull min/max/int constraints off a number schema's checks (and its `format`). */
103
+ function numBounds(d) {
104
+ let min = null;
105
+ let max = null;
106
+ let int = d.format !== void 0 && INT_FORMATS.has(d.format);
107
+ for (const c of checks(d)) if (c.check === "greater_than" && typeof c.value === "number") min = c.inclusive ? c.value : c.value + 1;
108
+ else if (c.check === "less_than" && typeof c.value === "number") max = c.inclusive ? c.value : c.value - 1;
109
+ else if (c.check === "number_format") int = true;
110
+ return {
111
+ min,
112
+ max,
113
+ int
114
+ };
115
+ }
116
+ /** Pull min/max length constraints off a string/array schema's checks. */
117
+ function lenBounds(d) {
118
+ let min = null;
119
+ let max = null;
120
+ for (const c of checks(d)) if (c.check === "min_length" && typeof c.minimum === "number") min = c.minimum;
121
+ else if (c.check === "max_length" && typeof c.maximum === "number") max = c.maximum;
122
+ else if (c.check === "length_equals" && typeof c.length === "number") min = max = c.length;
123
+ return {
124
+ min,
125
+ max
126
+ };
127
+ }
128
+ /** Draw an integer in `[lo, hi]` (inclusive) from the rng. */
129
+ function intIn(rng, lo, hi) {
130
+ /* v8 ignore next */ if (hi < lo) return lo;
131
+ return lo + Math.floor(rng() * (hi - lo + 1));
132
+ }
133
+ /** Pick a random element of a non-empty array. */
134
+ function pick(rng, arr) {
135
+ return arr[Math.floor(rng() * arr.length)];
136
+ }
137
+ /** Build a lowercase word of `len` characters. */
138
+ function word(rng, len) {
139
+ let s = "";
140
+ for (let i = 0; i < len; i++) s += ALPHABET[Math.floor(rng() * 26)];
141
+ return s;
142
+ }
143
+ /** Build a hex string of `len` characters. */
144
+ function hex(rng, len) {
145
+ let s = "";
146
+ for (let i = 0; i < len; i++) s += HEX[Math.floor(rng() * 16)];
147
+ return s;
148
+ }
149
+ /** Build a value for a detected/declared zod string `format`. */
150
+ function formatValue(format, rng, now) {
151
+ switch (format) {
152
+ case "email": return `${word(rng, WORD_LENGTH)}@example.com`;
153
+ case "url": return `https://${word(rng, WORD_LENGTH)}.example.com`;
154
+ case "uuid":
155
+ case "guid": return `${hex(rng, 8)}-${hex(rng, 4)}-4${hex(rng, 3)}-8${hex(rng, 3)}-${hex(rng, 12)}`;
156
+ case "datetime": return new Date(now).toISOString();
157
+ case "date": return new Date(now).toISOString().slice(0, 10);
158
+ case "time": return new Date(now).toISOString().slice(11, 23);
159
+ case "duration": return `PT${intIn(rng, 1, 60)}M`;
160
+ case "ipv4": return `${intIn(rng, 1, 254)}.${intIn(rng, 0, 255)}.${intIn(rng, 0, 255)}.${intIn(rng, 1, 254)}`;
161
+ case "ipv6": return `${hex(rng, 4)}:${hex(rng, 4)}::${hex(rng, 4)}`;
162
+ case "cuid":
163
+ case "cuid2": return `c${word(rng, 24)}`;
164
+ case "ulid": return hex(rng, 26).toUpperCase();
165
+ case "nanoid": return word(rng, 21);
166
+ case "emoji": return pick(rng, [
167
+ "😀",
168
+ "🎉",
169
+ "🚀",
170
+ "🌟",
171
+ "🔥"
172
+ ]);
173
+ case "e164": return `+1${intIn(rng, 2e9, 9999999999)}`;
174
+ case "base64": return Buffer.from(word(rng, WORD_LENGTH)).toString("base64");
175
+ case "base64url": return Buffer.from(word(rng, WORD_LENGTH)).toString("base64url");
176
+ default: return word(rng, WORD_LENGTH);
177
+ }
178
+ }
179
+ /** Build a plain string whose length lands within the schema's min/max bounds. */
180
+ function plainString(rng, bounds) {
181
+ const min = bounds.min ?? 0;
182
+ return word(rng, intIn(rng, min, bounds.max ?? Math.max(min, WORD_LENGTH)));
183
+ }
184
+ /** Build a number honoring int/min/max constraints. */
185
+ function genNumber(d, rng) {
186
+ const { min, max, int } = numBounds(d);
187
+ if (int) {
188
+ const lo = min ?? 0;
189
+ return intIn(rng, lo, max ?? lo + INT_RANGE);
190
+ }
191
+ const lo = min ?? 0;
192
+ const hi = max ?? lo + INT_RANGE;
193
+ const raw = lo + rng() * (hi - lo);
194
+ return Math.round(raw * FLOAT_PRECISION) / FLOAT_PRECISION;
195
+ }
196
+ /** Build a bigint honoring min/max constraints. */
197
+ function genBigint(d, rng) {
198
+ let min = null;
199
+ let max = null;
200
+ for (const c of checks(d)) if (c.check === "greater_than" && typeof c.value === "bigint") min = c.inclusive ? c.value : c.value + 1n;
201
+ else if (c.check === "less_than" && typeof c.value === "bigint") max = c.inclusive ? c.value : c.value - 1n;
202
+ const lo = min ?? 0n;
203
+ const span = (max ?? lo + BigInt(BIGINT_RANGE)) - lo;
204
+ if (span <= 0n) return lo;
205
+ return lo + BigInt(Math.floor(rng() * BIGINT_RANGE)) % (span + 1n);
206
+ }
207
+ /**
208
+ * Resolve a collection size from the request query. A `limit`-style query key
209
+ * (the first of {@link MockOptions.limitKeys | limitKeys} present and non-negative)
210
+ * wins; otherwise the configured default {@link MockOptions.arraySize | arraySize}
211
+ * is used. Shared by array generation and `streamOut` item counting so pagination
212
+ * is consistent across both.
213
+ */
214
+ function resolveSize(query, cfg) {
215
+ for (const key of cfg.limitKeys) {
216
+ const raw = query[key];
217
+ if (typeof raw !== "string" && typeof raw !== "number") continue;
218
+ const n = Number(raw);
219
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n);
220
+ }
221
+ return cfg.arraySize;
222
+ }
223
+ /** Last path segment (the field/property name) for override lookup. */
224
+ function leaf(path) {
225
+ const i = path.lastIndexOf(".");
226
+ return i < 0 ? path : path.slice(i + 1);
227
+ }
228
+ /**
229
+ * Generate a value for `schema` at `ctx.path`. Recurses over the zod type tree,
230
+ * applying field overrides (by leaf name or full path) and format overrides.
231
+ */
232
+ function genValue(schema, ctx, cfg) {
233
+ const fieldOverride = cfg.overrides.fields[ctx.path] ?? cfg.overrides.fields[leaf(ctx.path)];
234
+ if (fieldOverride) return fieldOverride(ctx);
235
+ const d = def(schema);
236
+ switch (d.type) {
237
+ case "string": {
238
+ const format = d.format;
239
+ if (format) {
240
+ const fmtOverride = cfg.overrides.formats[format];
241
+ if (fmtOverride) return fmtOverride(ctx);
242
+ return formatValue(format, ctx.rng, cfg.now());
243
+ }
244
+ return plainString(ctx.rng, lenBounds(d));
245
+ }
246
+ case "number": return genNumber(d, ctx.rng);
247
+ case "bigint": return genBigint(d, ctx.rng);
248
+ case "boolean": return ctx.rng() < .5;
249
+ case "date": return new Date(cfg.now());
250
+ case "literal": return d.values[0];
251
+ case "enum": return pick(ctx.rng, Object.values(d.entries));
252
+ case "object": {
253
+ const out = {};
254
+ for (const [key, child] of Object.entries(d.shape)) {
255
+ const childPath = ctx.path ? `${ctx.path}.${key}` : key;
256
+ out[key] = genValue(child, {
257
+ ...ctx,
258
+ path: childPath
259
+ }, cfg);
260
+ }
261
+ return out;
262
+ }
263
+ case "array": {
264
+ const n = resolveSize(ctx.query, cfg);
265
+ const bounds = lenBounds(d);
266
+ const count = Math.max(bounds.min ?? 0, Math.min(n, bounds.max ?? n));
267
+ const out = [];
268
+ for (let i = 0; i < count; i++) out.push(genValue(d.element, {
269
+ ...ctx,
270
+ path: `${ctx.path}.${i}`
271
+ }, cfg));
272
+ return out;
273
+ }
274
+ case "tuple": {
275
+ const out = d.items.map((item, i) => genValue(item, {
276
+ ...ctx,
277
+ path: `${ctx.path}.${i}`
278
+ }, cfg));
279
+ if (d.rest) out.push(genValue(d.rest, {
280
+ ...ctx,
281
+ path: `${ctx.path}.${out.length}`
282
+ }, cfg));
283
+ return out;
284
+ }
285
+ case "record": {
286
+ const out = {};
287
+ for (let i = 0; i < RECORD_SIZE; i++) {
288
+ const key = word(ctx.rng, WORD_LENGTH);
289
+ out[key] = genValue(d.valueType, {
290
+ ...ctx,
291
+ path: `${ctx.path}.${key}`
292
+ }, cfg);
293
+ }
294
+ return out;
295
+ }
296
+ case "union": return genValue(pick(ctx.rng, d.options), ctx, cfg);
297
+ case "optional":
298
+ if (ctx.rng() < PRESENCE_PROB) return genValue(d.innerType, ctx, cfg);
299
+ return;
300
+ case "nullable":
301
+ if (ctx.rng() < PRESENCE_PROB) return genValue(d.innerType, ctx, cfg);
302
+ return null;
303
+ case "default":
304
+ case "prefault":
305
+ if (ctx.rng() < PRESENCE_PROB) return genValue(d.innerType, ctx, cfg);
306
+ return d.defaultValue;
307
+ case "catch":
308
+ case "readonly":
309
+ case "nonoptional": return genValue(d.innerType, ctx, cfg);
310
+ default: return fallback(d, ctx);
311
+ }
312
+ }
313
+ /** Sensible value for schema types we don't model precisely (any/unknown/null/etc.). */
314
+ function fallback(d, ctx) {
315
+ switch (d.type) {
316
+ case "null": return null;
317
+ case "undefined":
318
+ case "void": return;
319
+ default: return word(ctx.rng, WORD_LENGTH);
320
+ }
321
+ }
322
+ /** Build the resolved {@link GenConfig} from public {@link MockOptions}. */
323
+ function resolveConfig(opts) {
324
+ return {
325
+ arraySize: opts?.arraySize ?? 3,
326
+ limitKeys: opts?.limitKeys ?? DEFAULT_LIMIT_KEYS,
327
+ overrides: {
328
+ fields: { ...opts?.overrides?.fields ?? {} },
329
+ formats: { ...opts?.overrides?.formats ?? {} }
330
+ },
331
+ now: opts?.now ?? Date.now
332
+ };
333
+ }
334
+ /** Build the per-generation RNG: deterministic hash-seeded, or `Math.random`. */
335
+ function makeRng(opts, ...seedParts) {
336
+ if (opts?.deterministic === false) return Math.random;
337
+ return rngFromParts(String(opts?.seed ?? DEFAULT_SEED), ...seedParts);
338
+ }
339
+ /**
340
+ * Generate one schema-valid fake value from a zod schema.
341
+ *
342
+ * @param schema - any zod schema.
343
+ * @param opts - seeding / sizing / override options.
344
+ * @param ctx - partial context overrides (e.g. a fixed `path`, `request`, `query`).
345
+ * @returns a value that parses against `schema`.
346
+ *
347
+ * @example
348
+ * ```ts
349
+ * const user = generate(z.object({ id: z.uuid(), name: z.string() }), { seed: 1 })
350
+ * ```
351
+ */
352
+ function generate(schema, opts, ctx) {
353
+ const cfg = resolveConfig(opts);
354
+ const rng = ctx?.rng ?? makeRng(opts, JSON.stringify(ctx?.request ?? null));
355
+ return genValue(schema, {
356
+ path: ctx?.path ?? "",
357
+ rng,
358
+ request: ctx?.request,
359
+ query: ctx?.query ?? {}
360
+ }, cfg);
361
+ }
362
+ //#endregion
363
+ //#region src/server.ts
364
+ /** Build a {@link GenContext} from a handler payload + endpoint name. */
365
+ function contextFor(name, payload, opts) {
366
+ const data = payload.data ?? {};
367
+ return {
368
+ path: "",
369
+ rng: makeRng(opts, name, JSON.stringify(payload.data ?? null)),
370
+ request: payload.data,
371
+ query: data
372
+ };
373
+ }
374
+ /** Read an endpoint config's schemas off its erased definition. */
375
+ function cfgOf(def) {
376
+ return def.cfg;
377
+ }
378
+ /** Smallest declared status in a multi-status map (deterministic choice). */
379
+ function firstStatus(responses) {
380
+ return Math.min(...Object.keys(responses).map(Number));
381
+ }
382
+ /** Build the handler for one endpoint, dispatching on its response shape. */
383
+ function handlerFor(name, def, opts) {
384
+ const cfg = cfgOf(def);
385
+ const gcfg = resolveConfig(opts);
386
+ if (cfg.streamOut && typeof cfg.streamOut !== "string") {
387
+ const itemSchema = cfg.streamOut;
388
+ return (payload) => {
389
+ const ctx = contextFor(name, payload, opts);
390
+ const n = resolveSize(ctx.query, gcfg);
391
+ return (async function* () {
392
+ for (let i = 0; i < n; i++) yield genValue(itemSchema, {
393
+ ...ctx,
394
+ path: String(i)
395
+ }, gcfg);
396
+ })();
397
+ };
398
+ }
399
+ if (cfg.responses) {
400
+ const responses = cfg.responses;
401
+ const status = firstStatus(responses);
402
+ const schema = responses[status];
403
+ return (payload) => {
404
+ return {
405
+ status,
406
+ data: genValue(schema, contextFor(name, payload, opts), gcfg)
407
+ };
408
+ };
409
+ }
410
+ if (cfg.response) {
411
+ const schema = cfg.response;
412
+ return (payload) => genValue(schema, contextFor(name, payload, opts), gcfg);
413
+ }
414
+ return () => void 0;
415
+ }
416
+ /**
417
+ * Build the handler bag for a spec — one generated handler per endpoint, suitable
418
+ * for `server(spec, [implement(spec).handlers(bag)])`.
419
+ *
420
+ * @example
421
+ * ```ts
422
+ * const app = server(api, [implement(api).handlers(mockHandlers(api, { seed: 1 }))])
423
+ * ```
424
+ */
425
+ function mockHandlers(spec, opts) {
426
+ const bag = {};
427
+ for (const [name, def] of Object.entries(spec.endpoints)) bag[name] = handlerFor(name, def, opts);
428
+ return bag;
429
+ }
430
+ /**
431
+ * Build a real ayepi {@link Server} for a spec where every endpoint returns
432
+ * generated, schema-valid data. Routing, input validation, output validation, and
433
+ * the OpenAPI/AsyncAPI docs all behave exactly as a real server's.
434
+ *
435
+ * @example
436
+ * ```ts
437
+ * const app = mockServer(api, { seed: 1, arraySize: 5 })
438
+ * const res = await app.fetch(new Request('http://x/listUsers?limit=10', { method: 'POST' }))
439
+ * ```
440
+ */
441
+ function mockServer(spec, opts) {
442
+ const bag = mockHandlers(spec, opts);
443
+ return (0, _ayepi_core.server)(spec, [(0, _ayepi_core.implement)(spec).handlers(bag)]);
444
+ }
445
+ //#endregion
446
+ exports.generate = generate;
447
+ exports.mockHandlers = mockHandlers;
448
+ exports.mockServer = mockServer;
@@ -0,0 +1,109 @@
1
+ import { z } from "zod";
2
+ import { AnySpec, Server } from "@ayepi/core";
3
+
4
+ //#region src/types.d.ts
5
+
6
+ /**
7
+ * # Public option & context types
8
+ *
9
+ * The configuration surface ({@link MockOptions}) and the per-generation context
10
+ * ({@link GenContext}) passed to override callbacks. Kept in their own module so
11
+ * both the generator and the server entry points can import them without a cycle.
12
+ *
13
+ * @module
14
+ */
15
+ /**
16
+ * Context handed to override callbacks (and threaded through the generator). It
17
+ * exposes where in the schema we are ({@link GenContext.path | path}), the seeded
18
+ * {@link GenContext.rng | rng}, and the request inputs that seeded it.
19
+ */
20
+ interface GenContext {
21
+ /** Dotted path to the value being generated, e.g. `user.address.city` or `items.0`. */
22
+ readonly path: string;
23
+ /** The seeded PRNG for this generation — call it for floats in `[0, 1)`. */
24
+ readonly rng: () => number;
25
+ /** The full request data that seeded this generation (server mocks), else `undefined`. */
26
+ readonly request: unknown;
27
+ /** The request query params that seeded this generation (drives pagination). */
28
+ readonly query: Record<string, unknown>;
29
+ }
30
+ /** An override callback: receives the {@link GenContext} and returns a value. */
31
+ type Override = (g: GenContext) => unknown;
32
+ /** Per-field and per-format override tables. */
33
+ interface MockOverrides {
34
+ /** Keyed by property name or dotted path (e.g. `email` or `user.email`). */
35
+ readonly fields?: Readonly<Record<string, Override>>;
36
+ /** Keyed by detected zod string format (e.g. `email`, `uuid`, `url`, `datetime`). */
37
+ readonly formats?: Readonly<Record<string, Override>>;
38
+ }
39
+ /**
40
+ * Configuration for fake-data generation and the mock server.
41
+ *
42
+ * The defaults aim for *deterministic* output: with {@link MockOptions.deterministic}
43
+ * on (the default), the PRNG is seeded from `seed + endpoint + JSON(request)`, so the
44
+ * same inputs always produce the same bytes — which is what makes paginated responses
45
+ * stable across calls.
46
+ */
47
+ interface MockOptions {
48
+ /** Base seed; combined with the endpoint + request to seed the PRNG. Default `0`. */
49
+ readonly seed?: number | string;
50
+ /** When `true` (default), generation is a pure function of seed + inputs. When `false`, uses `Math.random`. */
51
+ readonly deterministic?: boolean;
52
+ /** Default element count for arrays with no size hint. Default `3`. */
53
+ readonly arraySize?: number;
54
+ /** Query keys whose value sizes generated arrays (pagination). Default `['limit', 'pageSize', 'count']`. */
55
+ readonly limitKeys?: readonly string[];
56
+ /** Per-field / per-format value overrides. */
57
+ readonly overrides?: MockOverrides;
58
+ /** Clock used for date generation (injectable for tests). Default `Date.now`. */
59
+ readonly now?: () => number;
60
+ }
61
+ //#endregion
62
+ //#region src/generate.d.ts
63
+
64
+ /**
65
+ * Generate one schema-valid fake value from a zod schema.
66
+ *
67
+ * @param schema - any zod schema.
68
+ * @param opts - seeding / sizing / override options.
69
+ * @param ctx - partial context overrides (e.g. a fixed `path`, `request`, `query`).
70
+ * @returns a value that parses against `schema`.
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * const user = generate(z.object({ id: z.uuid(), name: z.string() }), { seed: 1 })
75
+ * ```
76
+ */
77
+ declare function generate(schema: z.ZodType, opts?: MockOptions, ctx?: Partial<GenContext>): unknown;
78
+ //#endregion
79
+ //#region src/server.d.ts
80
+ /** A handler payload, narrowed to the parts the mock reads. */
81
+ interface MockPayload {
82
+ readonly data?: unknown;
83
+ }
84
+ /** A single generated mock handler (erased payload/return types). */
85
+ type MockHandler = (payload: MockPayload) => unknown;
86
+ /**
87
+ * Build the handler bag for a spec — one generated handler per endpoint, suitable
88
+ * for `server(spec, [implement(spec).handlers(bag)])`.
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * const app = server(api, [implement(api).handlers(mockHandlers(api, { seed: 1 }))])
93
+ * ```
94
+ */
95
+ declare function mockHandlers(spec: AnySpec, opts?: MockOptions): Record<string, MockHandler>;
96
+ /**
97
+ * Build a real ayepi {@link Server} for a spec where every endpoint returns
98
+ * generated, schema-valid data. Routing, input validation, output validation, and
99
+ * the OpenAPI/AsyncAPI docs all behave exactly as a real server's.
100
+ *
101
+ * @example
102
+ * ```ts
103
+ * const app = mockServer(api, { seed: 1, arraySize: 5 })
104
+ * const res = await app.fetch(new Request('http://x/listUsers?limit=10', { method: 'POST' }))
105
+ * ```
106
+ */
107
+ declare function mockServer(spec: AnySpec, opts?: MockOptions): Server<AnySpec>;
108
+ //#endregion
109
+ export { type GenContext, type MockOptions, type MockOverrides, type Override, generate, mockHandlers, mockServer };
@@ -0,0 +1,109 @@
1
+ import { AnySpec, Server } from "@ayepi/core";
2
+ import { z } from "zod";
3
+
4
+ //#region src/types.d.ts
5
+
6
+ /**
7
+ * # Public option & context types
8
+ *
9
+ * The configuration surface ({@link MockOptions}) and the per-generation context
10
+ * ({@link GenContext}) passed to override callbacks. Kept in their own module so
11
+ * both the generator and the server entry points can import them without a cycle.
12
+ *
13
+ * @module
14
+ */
15
+ /**
16
+ * Context handed to override callbacks (and threaded through the generator). It
17
+ * exposes where in the schema we are ({@link GenContext.path | path}), the seeded
18
+ * {@link GenContext.rng | rng}, and the request inputs that seeded it.
19
+ */
20
+ interface GenContext {
21
+ /** Dotted path to the value being generated, e.g. `user.address.city` or `items.0`. */
22
+ readonly path: string;
23
+ /** The seeded PRNG for this generation — call it for floats in `[0, 1)`. */
24
+ readonly rng: () => number;
25
+ /** The full request data that seeded this generation (server mocks), else `undefined`. */
26
+ readonly request: unknown;
27
+ /** The request query params that seeded this generation (drives pagination). */
28
+ readonly query: Record<string, unknown>;
29
+ }
30
+ /** An override callback: receives the {@link GenContext} and returns a value. */
31
+ type Override = (g: GenContext) => unknown;
32
+ /** Per-field and per-format override tables. */
33
+ interface MockOverrides {
34
+ /** Keyed by property name or dotted path (e.g. `email` or `user.email`). */
35
+ readonly fields?: Readonly<Record<string, Override>>;
36
+ /** Keyed by detected zod string format (e.g. `email`, `uuid`, `url`, `datetime`). */
37
+ readonly formats?: Readonly<Record<string, Override>>;
38
+ }
39
+ /**
40
+ * Configuration for fake-data generation and the mock server.
41
+ *
42
+ * The defaults aim for *deterministic* output: with {@link MockOptions.deterministic}
43
+ * on (the default), the PRNG is seeded from `seed + endpoint + JSON(request)`, so the
44
+ * same inputs always produce the same bytes — which is what makes paginated responses
45
+ * stable across calls.
46
+ */
47
+ interface MockOptions {
48
+ /** Base seed; combined with the endpoint + request to seed the PRNG. Default `0`. */
49
+ readonly seed?: number | string;
50
+ /** When `true` (default), generation is a pure function of seed + inputs. When `false`, uses `Math.random`. */
51
+ readonly deterministic?: boolean;
52
+ /** Default element count for arrays with no size hint. Default `3`. */
53
+ readonly arraySize?: number;
54
+ /** Query keys whose value sizes generated arrays (pagination). Default `['limit', 'pageSize', 'count']`. */
55
+ readonly limitKeys?: readonly string[];
56
+ /** Per-field / per-format value overrides. */
57
+ readonly overrides?: MockOverrides;
58
+ /** Clock used for date generation (injectable for tests). Default `Date.now`. */
59
+ readonly now?: () => number;
60
+ }
61
+ //#endregion
62
+ //#region src/generate.d.ts
63
+
64
+ /**
65
+ * Generate one schema-valid fake value from a zod schema.
66
+ *
67
+ * @param schema - any zod schema.
68
+ * @param opts - seeding / sizing / override options.
69
+ * @param ctx - partial context overrides (e.g. a fixed `path`, `request`, `query`).
70
+ * @returns a value that parses against `schema`.
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * const user = generate(z.object({ id: z.uuid(), name: z.string() }), { seed: 1 })
75
+ * ```
76
+ */
77
+ declare function generate(schema: z.ZodType, opts?: MockOptions, ctx?: Partial<GenContext>): unknown;
78
+ //#endregion
79
+ //#region src/server.d.ts
80
+ /** A handler payload, narrowed to the parts the mock reads. */
81
+ interface MockPayload {
82
+ readonly data?: unknown;
83
+ }
84
+ /** A single generated mock handler (erased payload/return types). */
85
+ type MockHandler = (payload: MockPayload) => unknown;
86
+ /**
87
+ * Build the handler bag for a spec — one generated handler per endpoint, suitable
88
+ * for `server(spec, [implement(spec).handlers(bag)])`.
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * const app = server(api, [implement(api).handlers(mockHandlers(api, { seed: 1 }))])
93
+ * ```
94
+ */
95
+ declare function mockHandlers(spec: AnySpec, opts?: MockOptions): Record<string, MockHandler>;
96
+ /**
97
+ * Build a real ayepi {@link Server} for a spec where every endpoint returns
98
+ * generated, schema-valid data. Routing, input validation, output validation, and
99
+ * the OpenAPI/AsyncAPI docs all behave exactly as a real server's.
100
+ *
101
+ * @example
102
+ * ```ts
103
+ * const app = mockServer(api, { seed: 1, arraySize: 5 })
104
+ * const res = await app.fetch(new Request('http://x/listUsers?limit=10', { method: 'POST' }))
105
+ * ```
106
+ */
107
+ declare function mockServer(spec: AnySpec, opts?: MockOptions): Server<AnySpec>;
108
+ //#endregion
109
+ export { type GenContext, type MockOptions, type MockOverrides, type Override, generate, mockHandlers, mockServer };
package/dist/index.js ADDED
@@ -0,0 +1,445 @@
1
+ import { implement, server } from "@ayepi/core";
2
+ //#region src/rng.ts
3
+ /**
4
+ * # Seeded PRNG
5
+ *
6
+ * A tiny, dependency-free deterministic random number generator. {@link fnv1a}
7
+ * folds an arbitrary string into a 32-bit seed; {@link mulberry32} turns that
8
+ * seed into a `() => number` stream of floats in `[0, 1)`. Given the same seed
9
+ * the stream is byte-for-byte reproducible, which is what makes deterministic
10
+ * mocking — and therefore stable pagination — possible.
11
+ *
12
+ * @module
13
+ */
14
+ /** FNV-1a offset basis (32-bit). */
15
+ const FNV_OFFSET_BASIS = 2166136261;
16
+ /** FNV-1a prime (32-bit). */
17
+ const FNV_PRIME = 16777619;
18
+ /** Mask to keep arithmetic within 32 unsigned bits. */
19
+ const U32_MASK = 4294967295;
20
+ /** mulberry32 mixing constant added to the running state each step. */
21
+ const MULBERRY_INCREMENT = 1831565813;
22
+ /** mulberry32 second-round multiply constant. */
23
+ const MULBERRY_ROUND2 = 3266489909;
24
+ /** Divisor that maps a 32-bit unsigned integer into `[0, 1)`. */
25
+ const U32_DIVISOR = 4294967296;
26
+ /**
27
+ * Hash a string into a 32-bit unsigned integer with FNV-1a.
28
+ *
29
+ * Used to fold a composite seed (`baseSeed + endpoint + JSON(request)`) down to a
30
+ * single integer the PRNG can be initialized from.
31
+ */
32
+ function fnv1a(input) {
33
+ let hash = FNV_OFFSET_BASIS;
34
+ for (let i = 0; i < input.length; i++) {
35
+ hash ^= input.charCodeAt(i);
36
+ hash = Math.imul(hash, FNV_PRIME);
37
+ }
38
+ return hash >>> 0;
39
+ }
40
+ /**
41
+ * Build a deterministic `() => number` PRNG (floats in `[0, 1)`) from a 32-bit
42
+ * seed using the mulberry32 algorithm. Identical seeds yield identical streams.
43
+ */
44
+ function mulberry32(seed) {
45
+ let state = seed >>> 0;
46
+ return () => {
47
+ state = state + MULBERRY_INCREMENT & U32_MASK;
48
+ let t = state;
49
+ t = Math.imul(t ^ t >>> 15, t | 1);
50
+ t ^= t + Math.imul(t ^ t >>> 7, t | MULBERRY_ROUND2);
51
+ t = (t ^ t >>> 14) >>> 0;
52
+ return t / U32_DIVISOR;
53
+ };
54
+ }
55
+ /**
56
+ * Convenience: hash a string seed and return a fresh PRNG stream for it.
57
+ *
58
+ * @param parts - composite seed parts joined into the hash input.
59
+ */
60
+ function rngFromParts(...parts) {
61
+ return mulberry32(fnv1a(parts.join("\0")));
62
+ }
63
+ /** Default query keys whose value sizes generated arrays. */
64
+ const DEFAULT_LIMIT_KEYS = [
65
+ "limit",
66
+ "pageSize",
67
+ "count"
68
+ ];
69
+ /** Default base seed when none is supplied. */
70
+ const DEFAULT_SEED = "0";
71
+ /** Fallback string length used when a format/length constraint gives no better hint. */
72
+ const WORD_LENGTH = 8;
73
+ /** Default integer ceiling (exclusive) for unconstrained number generation. */
74
+ const INT_RANGE = 1e3;
75
+ /** Decimal places kept for unconstrained floats. */
76
+ const FLOAT_PRECISION = 1e3;
77
+ /** Probability an optional/nullable value is *present* (non-undefined / non-null). */
78
+ const PRESENCE_PROB = .7;
79
+ /** Record entry count when generating from a `z.record`. */
80
+ const RECORD_SIZE = 2;
81
+ /** Bound used for bigint generation when unconstrained. */
82
+ const BIGINT_RANGE = 1e3;
83
+ /** Number `format` values that require an integer result. */
84
+ const INT_FORMATS = new Set([
85
+ "safeint",
86
+ "int32",
87
+ "uint32"
88
+ ]);
89
+ /** Characters drawn from when padding/building plain strings. */
90
+ const ALPHABET = "abcdefghijklmnopqrstuvwxyz";
91
+ /** Hex characters for uuid/id-shaped formats. */
92
+ const HEX = "0123456789abcdef";
93
+ /** Read a schema's internal `_zod.def`. */
94
+ function def(schema) {
95
+ return schema._zod.def;
96
+ }
97
+ /** Read the (possibly empty) list of check descriptors on a schema. */
98
+ function checks(d) {
99
+ return (d.checks ?? []).map((c) => c._zod.def);
100
+ }
101
+ /** Pull min/max/int constraints off a number schema's checks (and its `format`). */
102
+ function numBounds(d) {
103
+ let min = null;
104
+ let max = null;
105
+ let int = d.format !== void 0 && INT_FORMATS.has(d.format);
106
+ for (const c of checks(d)) if (c.check === "greater_than" && typeof c.value === "number") min = c.inclusive ? c.value : c.value + 1;
107
+ else if (c.check === "less_than" && typeof c.value === "number") max = c.inclusive ? c.value : c.value - 1;
108
+ else if (c.check === "number_format") int = true;
109
+ return {
110
+ min,
111
+ max,
112
+ int
113
+ };
114
+ }
115
+ /** Pull min/max length constraints off a string/array schema's checks. */
116
+ function lenBounds(d) {
117
+ let min = null;
118
+ let max = null;
119
+ for (const c of checks(d)) if (c.check === "min_length" && typeof c.minimum === "number") min = c.minimum;
120
+ else if (c.check === "max_length" && typeof c.maximum === "number") max = c.maximum;
121
+ else if (c.check === "length_equals" && typeof c.length === "number") min = max = c.length;
122
+ return {
123
+ min,
124
+ max
125
+ };
126
+ }
127
+ /** Draw an integer in `[lo, hi]` (inclusive) from the rng. */
128
+ function intIn(rng, lo, hi) {
129
+ /* v8 ignore next */ if (hi < lo) return lo;
130
+ return lo + Math.floor(rng() * (hi - lo + 1));
131
+ }
132
+ /** Pick a random element of a non-empty array. */
133
+ function pick(rng, arr) {
134
+ return arr[Math.floor(rng() * arr.length)];
135
+ }
136
+ /** Build a lowercase word of `len` characters. */
137
+ function word(rng, len) {
138
+ let s = "";
139
+ for (let i = 0; i < len; i++) s += ALPHABET[Math.floor(rng() * 26)];
140
+ return s;
141
+ }
142
+ /** Build a hex string of `len` characters. */
143
+ function hex(rng, len) {
144
+ let s = "";
145
+ for (let i = 0; i < len; i++) s += HEX[Math.floor(rng() * 16)];
146
+ return s;
147
+ }
148
+ /** Build a value for a detected/declared zod string `format`. */
149
+ function formatValue(format, rng, now) {
150
+ switch (format) {
151
+ case "email": return `${word(rng, WORD_LENGTH)}@example.com`;
152
+ case "url": return `https://${word(rng, WORD_LENGTH)}.example.com`;
153
+ case "uuid":
154
+ case "guid": return `${hex(rng, 8)}-${hex(rng, 4)}-4${hex(rng, 3)}-8${hex(rng, 3)}-${hex(rng, 12)}`;
155
+ case "datetime": return new Date(now).toISOString();
156
+ case "date": return new Date(now).toISOString().slice(0, 10);
157
+ case "time": return new Date(now).toISOString().slice(11, 23);
158
+ case "duration": return `PT${intIn(rng, 1, 60)}M`;
159
+ case "ipv4": return `${intIn(rng, 1, 254)}.${intIn(rng, 0, 255)}.${intIn(rng, 0, 255)}.${intIn(rng, 1, 254)}`;
160
+ case "ipv6": return `${hex(rng, 4)}:${hex(rng, 4)}::${hex(rng, 4)}`;
161
+ case "cuid":
162
+ case "cuid2": return `c${word(rng, 24)}`;
163
+ case "ulid": return hex(rng, 26).toUpperCase();
164
+ case "nanoid": return word(rng, 21);
165
+ case "emoji": return pick(rng, [
166
+ "😀",
167
+ "🎉",
168
+ "🚀",
169
+ "🌟",
170
+ "🔥"
171
+ ]);
172
+ case "e164": return `+1${intIn(rng, 2e9, 9999999999)}`;
173
+ case "base64": return Buffer.from(word(rng, WORD_LENGTH)).toString("base64");
174
+ case "base64url": return Buffer.from(word(rng, WORD_LENGTH)).toString("base64url");
175
+ default: return word(rng, WORD_LENGTH);
176
+ }
177
+ }
178
+ /** Build a plain string whose length lands within the schema's min/max bounds. */
179
+ function plainString(rng, bounds) {
180
+ const min = bounds.min ?? 0;
181
+ return word(rng, intIn(rng, min, bounds.max ?? Math.max(min, WORD_LENGTH)));
182
+ }
183
+ /** Build a number honoring int/min/max constraints. */
184
+ function genNumber(d, rng) {
185
+ const { min, max, int } = numBounds(d);
186
+ if (int) {
187
+ const lo = min ?? 0;
188
+ return intIn(rng, lo, max ?? lo + INT_RANGE);
189
+ }
190
+ const lo = min ?? 0;
191
+ const hi = max ?? lo + INT_RANGE;
192
+ const raw = lo + rng() * (hi - lo);
193
+ return Math.round(raw * FLOAT_PRECISION) / FLOAT_PRECISION;
194
+ }
195
+ /** Build a bigint honoring min/max constraints. */
196
+ function genBigint(d, rng) {
197
+ let min = null;
198
+ let max = null;
199
+ for (const c of checks(d)) if (c.check === "greater_than" && typeof c.value === "bigint") min = c.inclusive ? c.value : c.value + 1n;
200
+ else if (c.check === "less_than" && typeof c.value === "bigint") max = c.inclusive ? c.value : c.value - 1n;
201
+ const lo = min ?? 0n;
202
+ const span = (max ?? lo + BigInt(BIGINT_RANGE)) - lo;
203
+ if (span <= 0n) return lo;
204
+ return lo + BigInt(Math.floor(rng() * BIGINT_RANGE)) % (span + 1n);
205
+ }
206
+ /**
207
+ * Resolve a collection size from the request query. A `limit`-style query key
208
+ * (the first of {@link MockOptions.limitKeys | limitKeys} present and non-negative)
209
+ * wins; otherwise the configured default {@link MockOptions.arraySize | arraySize}
210
+ * is used. Shared by array generation and `streamOut` item counting so pagination
211
+ * is consistent across both.
212
+ */
213
+ function resolveSize(query, cfg) {
214
+ for (const key of cfg.limitKeys) {
215
+ const raw = query[key];
216
+ if (typeof raw !== "string" && typeof raw !== "number") continue;
217
+ const n = Number(raw);
218
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n);
219
+ }
220
+ return cfg.arraySize;
221
+ }
222
+ /** Last path segment (the field/property name) for override lookup. */
223
+ function leaf(path) {
224
+ const i = path.lastIndexOf(".");
225
+ return i < 0 ? path : path.slice(i + 1);
226
+ }
227
+ /**
228
+ * Generate a value for `schema` at `ctx.path`. Recurses over the zod type tree,
229
+ * applying field overrides (by leaf name or full path) and format overrides.
230
+ */
231
+ function genValue(schema, ctx, cfg) {
232
+ const fieldOverride = cfg.overrides.fields[ctx.path] ?? cfg.overrides.fields[leaf(ctx.path)];
233
+ if (fieldOverride) return fieldOverride(ctx);
234
+ const d = def(schema);
235
+ switch (d.type) {
236
+ case "string": {
237
+ const format = d.format;
238
+ if (format) {
239
+ const fmtOverride = cfg.overrides.formats[format];
240
+ if (fmtOverride) return fmtOverride(ctx);
241
+ return formatValue(format, ctx.rng, cfg.now());
242
+ }
243
+ return plainString(ctx.rng, lenBounds(d));
244
+ }
245
+ case "number": return genNumber(d, ctx.rng);
246
+ case "bigint": return genBigint(d, ctx.rng);
247
+ case "boolean": return ctx.rng() < .5;
248
+ case "date": return new Date(cfg.now());
249
+ case "literal": return d.values[0];
250
+ case "enum": return pick(ctx.rng, Object.values(d.entries));
251
+ case "object": {
252
+ const out = {};
253
+ for (const [key, child] of Object.entries(d.shape)) {
254
+ const childPath = ctx.path ? `${ctx.path}.${key}` : key;
255
+ out[key] = genValue(child, {
256
+ ...ctx,
257
+ path: childPath
258
+ }, cfg);
259
+ }
260
+ return out;
261
+ }
262
+ case "array": {
263
+ const n = resolveSize(ctx.query, cfg);
264
+ const bounds = lenBounds(d);
265
+ const count = Math.max(bounds.min ?? 0, Math.min(n, bounds.max ?? n));
266
+ const out = [];
267
+ for (let i = 0; i < count; i++) out.push(genValue(d.element, {
268
+ ...ctx,
269
+ path: `${ctx.path}.${i}`
270
+ }, cfg));
271
+ return out;
272
+ }
273
+ case "tuple": {
274
+ const out = d.items.map((item, i) => genValue(item, {
275
+ ...ctx,
276
+ path: `${ctx.path}.${i}`
277
+ }, cfg));
278
+ if (d.rest) out.push(genValue(d.rest, {
279
+ ...ctx,
280
+ path: `${ctx.path}.${out.length}`
281
+ }, cfg));
282
+ return out;
283
+ }
284
+ case "record": {
285
+ const out = {};
286
+ for (let i = 0; i < RECORD_SIZE; i++) {
287
+ const key = word(ctx.rng, WORD_LENGTH);
288
+ out[key] = genValue(d.valueType, {
289
+ ...ctx,
290
+ path: `${ctx.path}.${key}`
291
+ }, cfg);
292
+ }
293
+ return out;
294
+ }
295
+ case "union": return genValue(pick(ctx.rng, d.options), ctx, cfg);
296
+ case "optional":
297
+ if (ctx.rng() < PRESENCE_PROB) return genValue(d.innerType, ctx, cfg);
298
+ return;
299
+ case "nullable":
300
+ if (ctx.rng() < PRESENCE_PROB) return genValue(d.innerType, ctx, cfg);
301
+ return null;
302
+ case "default":
303
+ case "prefault":
304
+ if (ctx.rng() < PRESENCE_PROB) return genValue(d.innerType, ctx, cfg);
305
+ return d.defaultValue;
306
+ case "catch":
307
+ case "readonly":
308
+ case "nonoptional": return genValue(d.innerType, ctx, cfg);
309
+ default: return fallback(d, ctx);
310
+ }
311
+ }
312
+ /** Sensible value for schema types we don't model precisely (any/unknown/null/etc.). */
313
+ function fallback(d, ctx) {
314
+ switch (d.type) {
315
+ case "null": return null;
316
+ case "undefined":
317
+ case "void": return;
318
+ default: return word(ctx.rng, WORD_LENGTH);
319
+ }
320
+ }
321
+ /** Build the resolved {@link GenConfig} from public {@link MockOptions}. */
322
+ function resolveConfig(opts) {
323
+ return {
324
+ arraySize: opts?.arraySize ?? 3,
325
+ limitKeys: opts?.limitKeys ?? DEFAULT_LIMIT_KEYS,
326
+ overrides: {
327
+ fields: { ...opts?.overrides?.fields ?? {} },
328
+ formats: { ...opts?.overrides?.formats ?? {} }
329
+ },
330
+ now: opts?.now ?? Date.now
331
+ };
332
+ }
333
+ /** Build the per-generation RNG: deterministic hash-seeded, or `Math.random`. */
334
+ function makeRng(opts, ...seedParts) {
335
+ if (opts?.deterministic === false) return Math.random;
336
+ return rngFromParts(String(opts?.seed ?? DEFAULT_SEED), ...seedParts);
337
+ }
338
+ /**
339
+ * Generate one schema-valid fake value from a zod schema.
340
+ *
341
+ * @param schema - any zod schema.
342
+ * @param opts - seeding / sizing / override options.
343
+ * @param ctx - partial context overrides (e.g. a fixed `path`, `request`, `query`).
344
+ * @returns a value that parses against `schema`.
345
+ *
346
+ * @example
347
+ * ```ts
348
+ * const user = generate(z.object({ id: z.uuid(), name: z.string() }), { seed: 1 })
349
+ * ```
350
+ */
351
+ function generate(schema, opts, ctx) {
352
+ const cfg = resolveConfig(opts);
353
+ const rng = ctx?.rng ?? makeRng(opts, JSON.stringify(ctx?.request ?? null));
354
+ return genValue(schema, {
355
+ path: ctx?.path ?? "",
356
+ rng,
357
+ request: ctx?.request,
358
+ query: ctx?.query ?? {}
359
+ }, cfg);
360
+ }
361
+ //#endregion
362
+ //#region src/server.ts
363
+ /** Build a {@link GenContext} from a handler payload + endpoint name. */
364
+ function contextFor(name, payload, opts) {
365
+ const data = payload.data ?? {};
366
+ return {
367
+ path: "",
368
+ rng: makeRng(opts, name, JSON.stringify(payload.data ?? null)),
369
+ request: payload.data,
370
+ query: data
371
+ };
372
+ }
373
+ /** Read an endpoint config's schemas off its erased definition. */
374
+ function cfgOf(def) {
375
+ return def.cfg;
376
+ }
377
+ /** Smallest declared status in a multi-status map (deterministic choice). */
378
+ function firstStatus(responses) {
379
+ return Math.min(...Object.keys(responses).map(Number));
380
+ }
381
+ /** Build the handler for one endpoint, dispatching on its response shape. */
382
+ function handlerFor(name, def, opts) {
383
+ const cfg = cfgOf(def);
384
+ const gcfg = resolveConfig(opts);
385
+ if (cfg.streamOut && typeof cfg.streamOut !== "string") {
386
+ const itemSchema = cfg.streamOut;
387
+ return (payload) => {
388
+ const ctx = contextFor(name, payload, opts);
389
+ const n = resolveSize(ctx.query, gcfg);
390
+ return (async function* () {
391
+ for (let i = 0; i < n; i++) yield genValue(itemSchema, {
392
+ ...ctx,
393
+ path: String(i)
394
+ }, gcfg);
395
+ })();
396
+ };
397
+ }
398
+ if (cfg.responses) {
399
+ const responses = cfg.responses;
400
+ const status = firstStatus(responses);
401
+ const schema = responses[status];
402
+ return (payload) => {
403
+ return {
404
+ status,
405
+ data: genValue(schema, contextFor(name, payload, opts), gcfg)
406
+ };
407
+ };
408
+ }
409
+ if (cfg.response) {
410
+ const schema = cfg.response;
411
+ return (payload) => genValue(schema, contextFor(name, payload, opts), gcfg);
412
+ }
413
+ return () => void 0;
414
+ }
415
+ /**
416
+ * Build the handler bag for a spec — one generated handler per endpoint, suitable
417
+ * for `server(spec, [implement(spec).handlers(bag)])`.
418
+ *
419
+ * @example
420
+ * ```ts
421
+ * const app = server(api, [implement(api).handlers(mockHandlers(api, { seed: 1 }))])
422
+ * ```
423
+ */
424
+ function mockHandlers(spec, opts) {
425
+ const bag = {};
426
+ for (const [name, def] of Object.entries(spec.endpoints)) bag[name] = handlerFor(name, def, opts);
427
+ return bag;
428
+ }
429
+ /**
430
+ * Build a real ayepi {@link Server} for a spec where every endpoint returns
431
+ * generated, schema-valid data. Routing, input validation, output validation, and
432
+ * the OpenAPI/AsyncAPI docs all behave exactly as a real server's.
433
+ *
434
+ * @example
435
+ * ```ts
436
+ * const app = mockServer(api, { seed: 1, arraySize: 5 })
437
+ * const res = await app.fetch(new Request('http://x/listUsers?limit=10', { method: 'POST' }))
438
+ * ```
439
+ */
440
+ function mockServer(spec, opts) {
441
+ const bag = mockHandlers(spec, opts);
442
+ return server(spec, [implement(spec).handlers(bag)]);
443
+ }
444
+ //#endregion
445
+ export { generate, mockHandlers, mockServer };
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@ayepi/mock",
3
+ "version": "0.1.0",
4
+ "description": "Mock server for @ayepi/core — generate schema-valid fake data from a spec, with deterministic seeding and automatic pagination",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/ClickerMonkey/ayepi.git",
12
+ "directory": "packages/mock"
13
+ },
14
+ "homepage": "https://github.com/ClickerMonkey/ayepi/tree/main/packages/mock#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/ClickerMonkey/ayepi/issues"
17
+ },
18
+ "type": "module",
19
+ "sideEffects": false,
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "exports": {
24
+ ".": {
25
+ "import": {
26
+ "types": "./dist/index.d.ts",
27
+ "default": "./dist/index.js"
28
+ },
29
+ "require": {
30
+ "types": "./dist/index.d.cts",
31
+ "default": "./dist/index.cjs"
32
+ }
33
+ },
34
+ "./package.json": "./package.json"
35
+ },
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "peerDependencies": {
40
+ "zod": "^4",
41
+ "@ayepi/core": "^0.1.0"
42
+ },
43
+ "devDependencies": {
44
+ "@vitest/coverage-v8": "^2.1.8",
45
+ "publint": "^0.3.0",
46
+ "tsdown": "^0.12.0",
47
+ "vitest": "^2.1.8",
48
+ "zod": "^4.4.3",
49
+ "@ayepi/core": "0.1.0"
50
+ },
51
+ "keywords": [
52
+ "ayepi",
53
+ "mock",
54
+ "faker",
55
+ "testing",
56
+ "fixtures",
57
+ "seed"
58
+ ],
59
+ "scripts": {
60
+ "build": "tsdown",
61
+ "typecheck": "tsc --noEmit",
62
+ "test": "vitest run --coverage",
63
+ "publint": "publint"
64
+ }
65
+ }