api-json-server 1.0.1 → 1.2.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.
Files changed (51) hide show
  1. package/README.md +636 -0
  2. package/dist/behavior.js +44 -0
  3. package/dist/history/historyRecorder.js +66 -0
  4. package/dist/history/types.js +2 -0
  5. package/dist/index.js +126 -5
  6. package/dist/loadSpec.js +32 -0
  7. package/dist/logger/customLogger.js +75 -0
  8. package/dist/logger/formatters.js +82 -0
  9. package/dist/logger/types.js +2 -0
  10. package/dist/openapi.js +152 -0
  11. package/dist/registerEndpoints.js +97 -0
  12. package/dist/requestMatch.js +99 -0
  13. package/dist/responseRenderer.js +98 -0
  14. package/dist/server.js +210 -0
  15. package/dist/spec.js +146 -0
  16. package/dist/stringTemplate.js +55 -0
  17. package/examples/auth-variants.json +31 -0
  18. package/examples/basic-crud.json +46 -0
  19. package/examples/companies-nested.json +47 -0
  20. package/examples/orders-and-matches.json +49 -0
  21. package/examples/users-faker.json +35 -0
  22. package/mock.spec.json +1 -0
  23. package/mockserve.spec.schema.json +7 -0
  24. package/package.json +20 -3
  25. package/scripts/build-schema.ts +21 -0
  26. package/src/behavior.ts +56 -0
  27. package/src/history/historyRecorder.ts +77 -0
  28. package/src/history/types.ts +25 -0
  29. package/src/index.ts +124 -85
  30. package/src/loadSpec.ts +5 -2
  31. package/src/logger/customLogger.ts +85 -0
  32. package/src/logger/formatters.ts +74 -0
  33. package/src/logger/types.ts +30 -0
  34. package/src/openapi.ts +203 -0
  35. package/src/registerEndpoints.ts +94 -162
  36. package/src/requestMatch.ts +104 -0
  37. package/src/responseRenderer.ts +112 -0
  38. package/src/server.ts +236 -14
  39. package/src/spec.ts +108 -8
  40. package/src/stringTemplate.ts +55 -0
  41. package/tests/behavior.test.ts +88 -0
  42. package/tests/cors.test.ts +128 -0
  43. package/tests/faker.test.ts +175 -0
  44. package/tests/fixtures/spec.basic.json +39 -0
  45. package/tests/headers.test.ts +124 -0
  46. package/tests/helpers.ts +28 -0
  47. package/tests/history.test.ts +188 -0
  48. package/tests/matching.test.ts +245 -0
  49. package/tests/server.test.ts +73 -0
  50. package/tests/template.test.ts +90 -0
  51. package/src/template.ts +0 -61
@@ -0,0 +1,104 @@
1
+ import type { FastifyRequest } from "fastify";
2
+ import type { Primitive } from "./spec.js";
3
+
4
+ export type MatchRule = {
5
+ query?: Record<string, Primitive>;
6
+ body?: Record<string, Primitive>;
7
+ headers?: Record<string, string>;
8
+ cookies?: Record<string, string>;
9
+ };
10
+
11
+ /**
12
+ * Safely coerce a value to a plain object record.
13
+ */
14
+ /**
15
+ * Safely coerce a value to a plain object record.
16
+ */
17
+ export function toRecord(value: unknown): Record<string, unknown> {
18
+ if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
19
+ return {};
20
+ }
21
+
22
+ /**
23
+ * Check if the request query matches the expected values.
24
+ */
25
+ function queryMatches(req: FastifyRequest, expected?: Record<string, Primitive>): boolean {
26
+ if (!expected) return true;
27
+ const query = toRecord(req.query);
28
+
29
+ for (const [key, exp] of Object.entries(expected)) {
30
+ const actual = query[key];
31
+ if (Array.isArray(actual)) return false;
32
+ if (String(actual ?? "") !== String(exp)) return false;
33
+ }
34
+
35
+ return true;
36
+ }
37
+
38
+ /**
39
+ * Check if the request body matches the expected top-level values.
40
+ */
41
+ function bodyMatches(req: FastifyRequest, expected?: Record<string, Primitive>): boolean {
42
+ if (!expected) return true;
43
+
44
+ const body = req.body;
45
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false;
46
+
47
+ const record = body as Record<string, unknown>;
48
+ for (const [key, exp] of Object.entries(expected)) {
49
+ const actual = record[key];
50
+ if (String(actual ?? "") !== String(exp)) return false;
51
+ }
52
+
53
+ return true;
54
+ }
55
+
56
+ /**
57
+ * Check if the request headers match the expected values (case-insensitive).
58
+ */
59
+ function headersMatch(req: FastifyRequest, expected?: Record<string, string>): boolean {
60
+ if (!expected) return true;
61
+
62
+ // Normalize header keys to lowercase for case-insensitive matching
63
+ const headers = new Map<string, string>();
64
+ for (const [key, value] of Object.entries(req.headers)) {
65
+ if (typeof value === "string") {
66
+ headers.set(key.toLowerCase(), value);
67
+ }
68
+ }
69
+
70
+ for (const [key, exp] of Object.entries(expected)) {
71
+ const actual = headers.get(key.toLowerCase());
72
+ if (actual !== exp) return false;
73
+ }
74
+
75
+ return true;
76
+ }
77
+
78
+ /**
79
+ * Check if the request cookies match the expected values.
80
+ */
81
+ function cookiesMatch(req: FastifyRequest, expected?: Record<string, string>): boolean {
82
+ if (!expected) return true;
83
+
84
+ const cookies = (req as FastifyRequest & { cookies?: Record<string, string> }).cookies;
85
+ if (!cookies) return false;
86
+
87
+ for (const [key, exp] of Object.entries(expected)) {
88
+ if (cookies[key] !== exp) return false;
89
+ }
90
+
91
+ return true;
92
+ }
93
+
94
+ /**
95
+ * Check if a request matches query/body/headers/cookies rules.
96
+ */
97
+ export function matchRequest(req: FastifyRequest, match?: MatchRule): boolean {
98
+ if (!match) return true;
99
+ if (!queryMatches(req, match.query)) return false;
100
+ if (!bodyMatches(req, match.body)) return false;
101
+ if (!headersMatch(req, match.headers)) return false;
102
+ if (!cookiesMatch(req, match.cookies)) return false;
103
+ return true;
104
+ }
@@ -0,0 +1,112 @@
1
+ import { faker as baseFaker, type Faker } from "@faker-js/faker";
2
+ import type { FakerTemplate, RepeatTemplate, TemplateValue } from "./spec.js";
3
+ import { renderStringTemplate, type TemplateContext } from "./stringTemplate.js";
4
+
5
+ export type RenderContext = TemplateContext & {
6
+ faker: Faker;
7
+ };
8
+
9
+ /**
10
+ * Create a render context with an optional faker seed applied.
11
+ */
12
+ export function createRenderContext(ctx: TemplateContext, fakerSeed?: number): RenderContext {
13
+ if (typeof fakerSeed === "number") {
14
+ baseFaker.seed(fakerSeed);
15
+ }
16
+ return { ...ctx, faker: baseFaker };
17
+ }
18
+
19
+ /**
20
+ * Check if a template value is a faker directive.
21
+ */
22
+ function isFakerTemplate(value: TemplateValue): value is FakerTemplate {
23
+ return typeof value === "object" && value !== null && !Array.isArray(value) && "__faker" in value;
24
+ }
25
+
26
+ /**
27
+ * Check if a template value is a repeat directive.
28
+ */
29
+ function isRepeatTemplate(value: TemplateValue): value is RepeatTemplate {
30
+ return typeof value === "object" && value !== null && !Array.isArray(value) && "__repeat" in value;
31
+ }
32
+
33
+ /**
34
+ * Resolve a faker method path (e.g. "person.firstName") to a callable function.
35
+ */
36
+ function resolveFakerMethod(faker: Faker, methodPath: string): (...args: unknown[]) => unknown {
37
+ const parts = methodPath.split(".");
38
+ let current: unknown = faker;
39
+
40
+ for (const part of parts) {
41
+ if (current === null || current === undefined) {
42
+ throw new Error(`Faker method not found: ${methodPath}`);
43
+ }
44
+ if (typeof current !== "object" && typeof current !== "function") {
45
+ throw new Error(`Faker method not found: ${methodPath}`);
46
+ }
47
+
48
+ const record = current as Record<string, unknown>;
49
+ current = record[part];
50
+ }
51
+
52
+ if (typeof current !== "function") {
53
+ throw new Error(`Faker method is not callable: ${methodPath}`);
54
+ }
55
+
56
+ return current as (...args: unknown[]) => unknown;
57
+ }
58
+
59
+ /**
60
+ * Render a faker directive to a concrete value.
61
+ */
62
+ function renderFakerTemplate(template: FakerTemplate, ctx: RenderContext): unknown {
63
+ const faker = ctx.faker;
64
+ const raw = template.__faker;
65
+ const methodPath = typeof raw === "string" ? raw : raw.method;
66
+ const args = typeof raw === "string" ? [] : raw.args ?? [];
67
+ const renderedArgs = args.map((arg) => renderTemplateValue(arg, ctx));
68
+ const method = resolveFakerMethod(faker, methodPath);
69
+ return method(...renderedArgs);
70
+ }
71
+
72
+ /**
73
+ * Render a repeat directive to an array of rendered items.
74
+ */
75
+ function renderRepeatTemplate(template: RepeatTemplate, ctx: RenderContext): unknown[] {
76
+ const faker = ctx.faker;
77
+ const { count, min, max, template: itemTemplate } = template.__repeat;
78
+ const minValue = typeof min === "number" ? min : 0;
79
+
80
+ if (typeof count === "number") {
81
+ return Array.from({ length: count }, () => renderTemplateValue(itemTemplate, ctx));
82
+ }
83
+
84
+ const upper = typeof max === "number" ? max : minValue;
85
+ if (upper < minValue) {
86
+ throw new Error(`Repeat max must be >= min (min=${minValue}, max=${upper})`);
87
+ }
88
+
89
+ const total = faker.number.int({ min: minValue, max: upper });
90
+ return Array.from({ length: total }, () => renderTemplateValue(itemTemplate, ctx));
91
+ }
92
+
93
+ /**
94
+ * Render a template value into a concrete JSON-compatible value.
95
+ */
96
+ export function renderTemplateValue(value: TemplateValue, ctx: RenderContext): unknown {
97
+ if (typeof value === "string") return renderStringTemplate(value, ctx);
98
+ if (typeof value === "number" || typeof value === "boolean" || value === null) return value;
99
+
100
+ if (Array.isArray(value)) {
101
+ return value.map((item) => renderTemplateValue(item, ctx));
102
+ }
103
+
104
+ if (isFakerTemplate(value)) return renderFakerTemplate(value, ctx);
105
+ if (isRepeatTemplate(value)) return renderRepeatTemplate(value, ctx);
106
+
107
+ const output: Record<string, unknown> = {};
108
+ for (const [key, item] of Object.entries(value)) {
109
+ output[key] = renderTemplateValue(item, ctx);
110
+ }
111
+ return output;
112
+ }
package/src/server.ts CHANGED
@@ -1,29 +1,251 @@
1
- import Fastify, { FastifyInstance } from "fastify";
2
- import { MockSpecInferSchema } from "./spec.js";
1
+ import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest, type FastifyBaseLogger } from "fastify";
2
+ import type { MockSpecInferSchema } from "./spec.js";
3
3
  import { registerEndpoints } from "./registerEndpoints.js";
4
+ import swaggerUiDist from "swagger-ui-dist";
5
+ import { generateOpenApi } from "./openapi.js";
6
+ import fastifyStatic from "@fastify/static";
7
+ import fastifyCookie from "@fastify/cookie";
8
+ import fastifyCors from "@fastify/cors";
9
+ import YAML from "yaml";
10
+ import { createLogger } from "./logger/customLogger.js";
11
+ import type { LoggerOptions } from "./logger/types.js";
12
+ import { HistoryRecorder } from "./history/historyRecorder.js";
13
+ import type { HistoryFilter } from "./history/types.js";
4
14
 
5
- export function buildServer(spec: MockSpecInferSchema, meta?: { specPath?: string; loadedAt?: string }): FastifyInstance {
15
+ type SwaggerUiDistModule = {
16
+ getAbsoluteFSPath?: () => string;
17
+ };
18
+
19
+ /**
20
+ * Resolve the path to swagger-ui-dist assets.
21
+ */
22
+ function getSwaggerUiRoot(): string {
23
+ // swagger-ui-dist can be CJS or ESM depending on environment.
24
+ // CJS: require("swagger-ui-dist").getAbsoluteFSPath()
25
+ // ESM: default export may itself be the function.
26
+ const mod: unknown = swaggerUiDist;
27
+
28
+ if (typeof mod === "function") return mod();
29
+ if (mod && typeof (mod as SwaggerUiDistModule).getAbsoluteFSPath === "function") {
30
+ return (mod as SwaggerUiDistModule).getAbsoluteFSPath!();
31
+ }
32
+
33
+ throw new Error("swagger-ui-dist: cannot determine absolute FS path to dist assets");
34
+ }
35
+
36
+ /**
37
+ * Resolve the server URL for OpenAPI output.
38
+ */
39
+ function resolveServerUrl(req: FastifyRequest, baseUrl?: string): string {
40
+ if (baseUrl && baseUrl.trim().length > 0) return baseUrl.trim();
41
+
42
+ const host = req.headers.host ?? "localhost";
43
+ const protocol = req.protocol ?? "http";
44
+ return `${protocol}://${host}`;
45
+ }
46
+
47
+ /**
48
+ * Create a Fastify server with mock endpoints and docs.
49
+ */
50
+ export function buildServer(
51
+ spec: MockSpecInferSchema,
52
+ meta?: { specPath?: string; loadedAt?: string; baseUrl?: string; logger?: FastifyBaseLogger | boolean }
53
+ ): FastifyInstance {
6
54
  const app = Fastify({
7
- logger: true
55
+ logger: meta?.logger ?? true,
56
+ trustProxy: true,
57
+ disableRequestLogging: false
8
58
  });
9
59
 
10
- // Basic sanity route
11
- app.get("/health", async () => {
12
- return { ok: true };
60
+ // Register CORS if configured
61
+ if (spec.settings.cors) {
62
+ app.register(fastifyCors, {
63
+ origin: spec.settings.cors.origin ?? true,
64
+ credentials: spec.settings.cors.credentials ?? false,
65
+ methods: spec.settings.cors.methods,
66
+ allowedHeaders: spec.settings.cors.allowedHeaders,
67
+ exposedHeaders: spec.settings.cors.exposedHeaders,
68
+ maxAge: spec.settings.cors.maxAge
69
+ });
70
+ }
71
+
72
+ // Register cookie parser plugin
73
+ app.register(fastifyCookie);
74
+
75
+ // Create history recorder
76
+ const history = new HistoryRecorder(1000);
77
+
78
+ // Record all requests in onRequest hook (after body parsing)
79
+ app.addHook("preHandler", async (req, reply) => {
80
+ const startTime = Date.now();
81
+
82
+ // Store start time for response hook
83
+ (req as FastifyRequest & { startTime?: number }).startTime = startTime;
84
+
85
+ history.record({
86
+ method: req.method,
87
+ url: req.url,
88
+ path: req.routeOptions?.url ?? req.url.split("?")[0],
89
+ query: req.query as Record<string, unknown>,
90
+ headers: req.headers,
91
+ body: req.body
92
+ });
93
+ });
94
+
95
+ // Update history with response details in onResponse hook
96
+ app.addHook("onResponse", async (req, reply) => {
97
+ const startTime = (req as FastifyRequest & { startTime?: number }).startTime ?? Date.now();
98
+ const responseTime = Date.now() - startTime;
99
+
100
+ // Find and update the last entry (just added in onRequest)
101
+ const entries = history.query({ limit: 1 });
102
+ if (entries.length > 0) {
103
+ const lastEntry = entries[entries.length - 1];
104
+ lastEntry.statusCode = reply.statusCode;
105
+ lastEntry.responseTime = responseTime;
106
+ }
13
107
  });
14
108
 
109
+ /**
110
+ * Handler for the /__spec route with bound metadata.
111
+ */
112
+ function specRouteHandler() {
113
+ return specHandler(spec, meta);
114
+ }
115
+
116
+ /**
117
+ * Handler for the /__openapi.json route.
118
+ */
119
+ function openApiJsonRouteHandler(req: FastifyRequest) {
120
+ return openApiJsonHandler(req, spec, meta?.baseUrl);
121
+ }
122
+
123
+ /**
124
+ * Handler for the /__openapi.yaml route.
125
+ */
126
+ function openApiYamlRouteHandler(req: FastifyRequest, reply: FastifyReply): void {
127
+ return openApiYamlHandler(req, reply, spec, meta?.baseUrl);
128
+ }
129
+
130
+ /**
131
+ * Handler for the /docs route.
132
+ */
133
+ function docsRouteHandler(req: FastifyRequest, reply: FastifyReply): void {
134
+ return docsHandler(req, reply);
135
+ }
136
+
137
+ // Basic sanity route
138
+ app.get("/health", healthHandler);
139
+
15
140
  // Internal inspection endpoint
16
- app.get("/__spec", async () => {
17
- return {
18
- meta: {
19
- specPath: meta?.specPath ?? null,
20
- loadedAt: meta?.loadedAt ?? null
21
- },
22
- spec
141
+ app.get("/__spec", specRouteHandler);
142
+
143
+ app.get("/__openapi.json", openApiJsonRouteHandler);
144
+
145
+ app.get("/__openapi.yaml", openApiYamlRouteHandler);
146
+
147
+ app.register(fastifyStatic, {
148
+ root: getSwaggerUiRoot(),
149
+ prefix: "/docs/assets/",
150
+ decorateReply: false
151
+ });
152
+
153
+ app.get("/docs", docsRouteHandler);
154
+
155
+ // History endpoints
156
+ app.get("/__history", async (req) => {
157
+ const query = req.query as Record<string, string>;
158
+ const filter: HistoryFilter = {
159
+ endpoint: query.endpoint,
160
+ method: query.method,
161
+ statusCode: query.statusCode ? Number(query.statusCode) : undefined,
162
+ limit: query.limit ? Number(query.limit) : undefined
23
163
  };
164
+ return { entries: history.query(filter), total: history.count() };
165
+ });
166
+
167
+ app.delete("/__history", async () => {
168
+ history.clear();
169
+ return { ok: true, message: "History cleared" };
24
170
  });
25
171
 
26
172
  registerEndpoints(app, spec);
27
173
 
28
174
  return app;
29
175
  }
176
+
177
+ /**
178
+ * Health check handler.
179
+ */
180
+ function healthHandler(): { ok: true } {
181
+ return { ok: true };
182
+ }
183
+
184
+ /**
185
+ * Debug handler that exposes the current spec and metadata.
186
+ */
187
+ function specHandler(spec: MockSpecInferSchema, meta?: { specPath?: string; loadedAt?: string }) {
188
+ return {
189
+ meta: {
190
+ specPath: meta?.specPath ?? null,
191
+ loadedAt: meta?.loadedAt ?? null
192
+ },
193
+ spec
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Serve the OpenAPI document as JSON.
199
+ */
200
+ function openApiJsonHandler(req: FastifyRequest, spec: MockSpecInferSchema, baseUrl?: string) {
201
+ const serverUrl = resolveServerUrl(req, baseUrl);
202
+ return generateOpenApi(spec, serverUrl);
203
+ }
204
+
205
+ /**
206
+ * Serve the OpenAPI document as YAML.
207
+ */
208
+ function openApiYamlHandler(
209
+ req: FastifyRequest,
210
+ reply: FastifyReply,
211
+ spec: MockSpecInferSchema,
212
+ baseUrl?: string
213
+ ): void {
214
+ const serverUrl = resolveServerUrl(req, baseUrl);
215
+ const doc = generateOpenApi(spec, serverUrl);
216
+ const yaml = YAML.stringify(doc);
217
+ reply.type("application/yaml; charset=utf-8").send(yaml);
218
+ }
219
+
220
+ /**
221
+ * Serve the Swagger UI HTML page.
222
+ */
223
+ function docsHandler(_req: FastifyRequest, reply: FastifyReply): void {
224
+ const html = `<!DOCTYPE html>
225
+ <html lang="en">
226
+ <head>
227
+ <meta charset="UTF-8" />
228
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
229
+ <title>mockserve docs</title>
230
+ <link rel="stylesheet" href="/docs/assets/swagger-ui.css" />
231
+ <style>
232
+ html, body { margin: 0; padding: 0; }
233
+ </style>
234
+ </head>
235
+ <body>
236
+ <div id="swagger-ui"></div>
237
+
238
+ <script src="/docs/assets/swagger-ui-bundle.js"></script>
239
+ <script src="/docs/assets/swagger-ui-standalone-preset.js"></script>
240
+ <script>
241
+ window.ui = SwaggerUIBundle({
242
+ url: "/__openapi.json",
243
+ dom_id: "#swagger-ui",
244
+ presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
245
+ layout: "BaseLayout"
246
+ });
247
+ </script>
248
+ </body>
249
+ </html>`;
250
+ reply.type("text/html; charset=utf-8").send(html);
251
+ }
package/src/spec.ts CHANGED
@@ -1,25 +1,100 @@
1
1
  import * as z from 'zod'
2
2
 
3
- const Primitive = z.union([z.string(), z.number(), z.boolean()]);
3
+ export type Primitive = string | number | boolean;
4
+
5
+ const PrimitiveSchema = z.union([z.string(), z.number(), z.boolean()]);
6
+
7
+ export interface TemplateArray extends Array<TemplateValue> {}
8
+
9
+ export interface TemplateObject {
10
+ [key: string]: TemplateValue;
11
+ }
12
+
13
+ export type FakerTemplate = {
14
+ __faker: string | { method: string; args?: TemplateValue[] };
15
+ };
16
+
17
+ export type RepeatTemplate = {
18
+ __repeat: {
19
+ min?: number;
20
+ max?: number;
21
+ count?: number;
22
+ template: TemplateValue;
23
+ };
24
+ };
25
+
26
+ export type TemplateValue =
27
+ | Primitive
28
+ | null
29
+ | TemplateArray
30
+ | TemplateObject
31
+ | FakerTemplate
32
+ | RepeatTemplate;
33
+
34
+ const TemplateValueSchema: z.ZodType<TemplateValue> = z.lazy(() =>
35
+ z.union([
36
+ PrimitiveSchema,
37
+ z.null(),
38
+ z.array(TemplateValueSchema),
39
+ z.object({ __faker: z.string().min(1) }).strict(),
40
+ z
41
+ .object({
42
+ __faker: z
43
+ .object({
44
+ method: z.string().min(1),
45
+ args: z.array(TemplateValueSchema).optional()
46
+ })
47
+ .strict()
48
+ })
49
+ .strict(),
50
+ z
51
+ .object({
52
+ __repeat: z
53
+ .object({
54
+ min: z.number().int().min(0).optional(),
55
+ max: z.number().int().min(0).optional(),
56
+ count: z.number().int().min(0).optional(),
57
+ template: TemplateValueSchema
58
+ })
59
+ .strict()
60
+ })
61
+ .strict(),
62
+ z.record(z.string(), TemplateValueSchema)
63
+ ])
64
+ );
4
65
 
5
66
  export const MatchSchema = z.object({
6
- query: z.record(z.string(), Primitive).optional(),
67
+ query: z.record(z.string(), PrimitiveSchema).optional(),
7
68
  // Exact match for top-level body fields only (keeps v1 simple)
8
- body: z.record(z.string(), Primitive).optional()
69
+ body: z.record(z.string(), PrimitiveSchema).optional(),
70
+ // Header matching (case-insensitive keys)
71
+ headers: z.record(z.string(), z.string()).optional(),
72
+ // Cookie matching
73
+ cookies: z.record(z.string(), z.string()).optional()
9
74
  });
10
75
 
76
+ const DelaySchema = z.union([
77
+ z.number().int().min(0),
78
+ z.object({
79
+ min: z.number().int().min(0),
80
+ max: z.number().int().min(0)
81
+ })
82
+ ]);
83
+
11
84
  export const VariantSchema = z.object({
12
85
  name: z.string().min(1).optional(),
13
86
  match: MatchSchema.optional(),
14
87
 
15
88
  status: z.number().int().min(100).max(599).optional(),
16
- response: z.unknown(),
89
+ response: TemplateValueSchema,
90
+ headers: z.record(z.string(), z.string()).optional(),
17
91
 
18
92
  // Simulation overrides per variant (optional)
19
93
  delayMs: z.number().int().min(0).optional(),
94
+ delay: DelaySchema.optional(),
20
95
  errorRate: z.number().min(0).max(1).optional(),
21
96
  errorStatus: z.number().int().min(100).max(599).optional(),
22
- errorResponse: z.unknown().optional()
97
+ errorResponse: TemplateValueSchema.optional()
23
98
  });
24
99
 
25
100
  export const EndpointSchema = z.object({
@@ -31,13 +106,27 @@ export const EndpointSchema = z.object({
31
106
 
32
107
  // Response behavior:
33
108
  status: z.number().int().min(200).max(599).default(200),
34
- response: z.unknown(),
109
+ response: TemplateValueSchema,
110
+ headers: z.record(z.string(), z.string()).optional(),
111
+
112
+ // Per-endpoint CORS override
113
+ cors: z
114
+ .object({
115
+ origin: z.union([z.string(), z.array(z.string()), z.boolean()]).optional(),
116
+ credentials: z.boolean().optional(),
117
+ methods: z.array(z.string()).optional(),
118
+ allowedHeaders: z.array(z.string()).optional(),
119
+ exposedHeaders: z.array(z.string()).optional(),
120
+ maxAge: z.number().int().optional()
121
+ })
122
+ .optional(),
35
123
 
36
124
  // Simulation (optional overrides)
37
125
  delayMs: z.number().int().min(0).optional(),
126
+ delay: DelaySchema.optional(),
38
127
  errorRate: z.number().min(0).max(1).optional(),
39
128
  errorStatus: z.number().int().min(100).max(599).optional(),
40
- errorResponse: z.unknown().optional()
129
+ errorResponse: TemplateValueSchema.optional()
41
130
  })
42
131
 
43
132
  export const MockSpecSchema = z.object({
@@ -47,7 +136,18 @@ export const MockSpecSchema = z.object({
47
136
  delayMs: z.number().int().min(0).default(0),
48
137
  errorRate: z.number().min(0).max(1).default(0),
49
138
  errorStatus: z.number().int().min(100).max(599).default(500),
50
- errorResponse: z.unknown().default({ error: "Mock error" })
139
+ errorResponse: TemplateValueSchema.default({ error: "Mock error" }),
140
+ fakerSeed: z.number().int().min(0).optional(),
141
+ cors: z
142
+ .object({
143
+ origin: z.union([z.string(), z.array(z.string()), z.boolean()]).optional(),
144
+ credentials: z.boolean().optional(),
145
+ methods: z.array(z.string()).optional(),
146
+ allowedHeaders: z.array(z.string()).optional(),
147
+ exposedHeaders: z.array(z.string()).optional(),
148
+ maxAge: z.number().int().optional()
149
+ })
150
+ .optional()
51
151
  })
52
152
  .default({ delayMs: 0, errorRate: 0, errorStatus: 500, errorResponse: { error: "Mock error" } }),
53
153
  endpoints: z.array(EndpointSchema).min(1)
@@ -0,0 +1,55 @@
1
+ export type TemplateContext = {
2
+ params: Record<string, unknown>;
3
+ query: Record<string, unknown>;
4
+ body: unknown;
5
+ };
6
+
7
+ /**
8
+ * Safely read a nested property from an object using a dot path.
9
+ */
10
+ export function getPathValue(obj: unknown, path: string): unknown {
11
+ if (!path) return undefined;
12
+
13
+ const parts = path.split(".");
14
+ let current: unknown = obj;
15
+
16
+ for (const part of parts) {
17
+ if (current === null || current === undefined) return undefined;
18
+ if (typeof current !== "object") return undefined;
19
+
20
+ const record = current as Record<string, unknown>;
21
+ current = record[part];
22
+ }
23
+
24
+ return current;
25
+ }
26
+
27
+ /**
28
+ * Render string templates like {{params.id}}, {{query.type}}, {{body.email}}.
29
+ */
30
+ export function renderStringTemplate(input: string, ctx: TemplateContext): string {
31
+ /**
32
+ * Replace a single template token with its resolved value.
33
+ */
34
+ function replaceToken(_match: string, root: string, path: string): string {
35
+ let source: unknown;
36
+ if (root === "params") source = ctx.params;
37
+ else if (root === "query") source = ctx.query;
38
+ else if (root === "body") source = ctx.body;
39
+ else return "";
40
+
41
+ const value = getPathValue(source, path);
42
+
43
+ if (value === undefined || value === null) return "";
44
+ if (typeof value === "string") return value;
45
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
46
+
47
+ try {
48
+ return JSON.stringify(value);
49
+ } catch {
50
+ return "";
51
+ }
52
+ }
53
+
54
+ return input.replace(/\{\{\s*([a-zA-Z]+)\.([a-zA-Z0-9_.]+)\s*\}\}/g, replaceToken);
55
+ }