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,85 @@
1
+ import type { FastifyBaseLogger } from "fastify";
2
+ import pino from "pino";
3
+ import type { LoggerOptions } from "./types.js";
4
+ import { formatStatusCode, formatMethod, formatResponseTime, formatTimestamp, formatLogLevel } from "./formatters.js";
5
+
6
+ /**
7
+ * Create a custom logger for the mock server.
8
+ */
9
+ export function createLogger(options: LoggerOptions): FastifyBaseLogger {
10
+ if (!options.enabled) {
11
+ return pino({ level: "silent" });
12
+ }
13
+
14
+ if (options.format === "json") {
15
+ return pino({
16
+ level: options.level,
17
+ timestamp: pino.stdTimeFunctions.isoTime
18
+ });
19
+ }
20
+
21
+ // Pretty format with custom output (simplified to avoid serialization issues in tests)
22
+ return pino({
23
+ level: options.level,
24
+ transport: {
25
+ target: "pino-pretty",
26
+ options: {
27
+ colorize: true,
28
+ translateTime: "HH:MM:ss",
29
+ ignore: "pid,hostname",
30
+ messageFormat: "{msg}"
31
+ }
32
+ }
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Format and log a request/response pair.
38
+ */
39
+ export function logRequest(
40
+ logger: FastifyBaseLogger,
41
+ method: string,
42
+ url: string,
43
+ statusCode: number,
44
+ responseTime: number
45
+ ): void {
46
+ const formattedMethod = formatMethod(method);
47
+ const formattedStatus = formatStatusCode(statusCode);
48
+ const formattedTime = formatResponseTime(responseTime);
49
+
50
+ logger.info(`${formattedMethod} ${url} ${formattedStatus} ${formattedTime}`);
51
+ }
52
+
53
+ /**
54
+ * Log server startup.
55
+ */
56
+ export function logServerStart(logger: FastifyBaseLogger, port: number, specPath: string): void {
57
+ logger.info(`🚀 Mock server running on http://localhost:${port}`);
58
+ logger.info(`📄 Spec: ${specPath}`);
59
+ logger.info(`📖 Docs: http://localhost:${port}/docs`);
60
+ }
61
+
62
+ /**
63
+ * Log server reload.
64
+ */
65
+ export function logServerReload(logger: FastifyBaseLogger, success: boolean, error?: string): void {
66
+ if (success) {
67
+ logger.info("✅ Spec reloaded successfully");
68
+ } else {
69
+ logger.error(`❌ Reload failed: ${error}`);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Log endpoint registration.
75
+ */
76
+ export function logEndpointRegistered(
77
+ logger: FastifyBaseLogger,
78
+ method: string,
79
+ path: string,
80
+ status?: number
81
+ ): void {
82
+ const formattedMethod = formatMethod(method);
83
+ const statusInfo = status ? ` → ${status}` : "";
84
+ logger.debug(`Registered ${formattedMethod} ${path}${statusInfo}`);
85
+ }
@@ -0,0 +1,74 @@
1
+ import chalk from "chalk";
2
+
3
+ /**
4
+ * Format HTTP status code with color based on status range.
5
+ */
6
+ export function formatStatusCode(statusCode: number): string {
7
+ if (statusCode >= 500) {
8
+ return chalk.red(statusCode.toString());
9
+ }
10
+ if (statusCode >= 400) {
11
+ return chalk.yellow(statusCode.toString());
12
+ }
13
+ if (statusCode >= 300) {
14
+ return chalk.cyan(statusCode.toString());
15
+ }
16
+ if (statusCode >= 200) {
17
+ return chalk.green(statusCode.toString());
18
+ }
19
+ return chalk.white(statusCode.toString());
20
+ }
21
+
22
+ /**
23
+ * Format HTTP method with color.
24
+ */
25
+ export function formatMethod(method: string): string {
26
+ const colors: Record<string, (str: string) => string> = {
27
+ GET: chalk.green,
28
+ POST: chalk.blue,
29
+ PUT: chalk.yellow,
30
+ PATCH: chalk.magenta,
31
+ DELETE: chalk.red,
32
+ HEAD: chalk.gray,
33
+ OPTIONS: chalk.cyan
34
+ };
35
+ const formatter = colors[method.toUpperCase()] || chalk.white;
36
+ return formatter(method.toUpperCase().padEnd(7));
37
+ }
38
+
39
+ /**
40
+ * Format response time with color based on duration.
41
+ */
42
+ export function formatResponseTime(ms: number): string {
43
+ const formatted = `${ms.toFixed(2)}ms`;
44
+ if (ms > 1000) return chalk.red(formatted);
45
+ if (ms > 500) return chalk.yellow(formatted);
46
+ if (ms > 100) return chalk.cyan(formatted);
47
+ return chalk.green(formatted);
48
+ }
49
+
50
+ /**
51
+ * Format timestamp in readable format.
52
+ */
53
+ export function formatTimestamp(date: Date): string {
54
+ const hours = date.getHours().toString().padStart(2, "0");
55
+ const minutes = date.getMinutes().toString().padStart(2, "0");
56
+ const seconds = date.getSeconds().toString().padStart(2, "0");
57
+ return chalk.gray(`[${hours}:${minutes}:${seconds}]`);
58
+ }
59
+
60
+ /**
61
+ * Format a log level with appropriate color.
62
+ */
63
+ export function formatLogLevel(level: string): string {
64
+ const colors: Record<string, (str: string) => string> = {
65
+ trace: chalk.gray,
66
+ debug: chalk.cyan,
67
+ info: chalk.blue,
68
+ warn: chalk.yellow,
69
+ error: chalk.red,
70
+ fatal: chalk.bgRed.white
71
+ };
72
+ const formatter = colors[level.toLowerCase()] || chalk.white;
73
+ return formatter(level.toUpperCase().padEnd(5));
74
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Logger configuration options.
3
+ */
4
+ export interface LoggerOptions {
5
+ /**
6
+ * Enable/disable logging.
7
+ */
8
+ enabled: boolean;
9
+
10
+ /**
11
+ * Log format: 'pretty' for colored console output, 'json' for structured logs.
12
+ */
13
+ format: "pretty" | "json";
14
+
15
+ /**
16
+ * Log level: 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
17
+ */
18
+ level: "trace" | "debug" | "info" | "warn" | "error" | "fatal";
19
+ }
20
+
21
+ /**
22
+ * Request log entry structure.
23
+ */
24
+ export interface RequestLogEntry {
25
+ method: string;
26
+ url: string;
27
+ statusCode: number;
28
+ responseTime: number;
29
+ timestamp: string;
30
+ }
package/src/openapi.ts ADDED
@@ -0,0 +1,203 @@
1
+ import type { MockSpecInferSchema } from "./spec.js";
2
+
3
+ type OpenApiParameter = {
4
+ name: string;
5
+ in: "path" | "query";
6
+ required: boolean;
7
+ schema: Record<string, unknown>;
8
+ description?: string;
9
+ };
10
+
11
+ type OpenApiRequestBody = {
12
+ required: boolean;
13
+ content: Record<string, { schema: Record<string, unknown> }>;
14
+ };
15
+
16
+ type OpenApiResponse = {
17
+ description: string;
18
+ content: Record<string, { examples: Record<string, { value: unknown }> }>;
19
+ };
20
+
21
+ type OpenApiOperation = {
22
+ summary: string;
23
+ parameters?: OpenApiParameter[];
24
+ requestBody?: OpenApiRequestBody;
25
+ responses: Record<string, OpenApiResponse>;
26
+ };
27
+
28
+ type OpenApi = {
29
+ openapi: string;
30
+ info: { title: string; version: string; description: string };
31
+ servers: Array<{ url: string }>;
32
+ paths: Record<string, Record<string, OpenApiOperation>>;
33
+ };
34
+
35
+ /**
36
+ * Convert Fastify-style route params to OpenAPI style.
37
+ */
38
+ function toOpenApiPath(fastifyPath: string): string {
39
+ // Fastify style: /users/:id -> OpenAPI style: /users/{id}
40
+ return fastifyPath.replace(/:([A-Za-z0-9_]+)/g, "{$1}");
41
+ }
42
+
43
+ /**
44
+ * Extract path parameter names from a Fastify-style route.
45
+ */
46
+ function extractPathParams(fastifyPath: string): string[] {
47
+ const matches = [...fastifyPath.matchAll(/:([A-Za-z0-9_]+)/g)];
48
+ return matches.map((m) => m[1]);
49
+ }
50
+
51
+ /**
52
+ * Deduplicate values while preserving order.
53
+ */
54
+ function uniq<T>(items: T[]): T[] {
55
+ return [...new Set(items)];
56
+ }
57
+
58
+ /**
59
+ * Convert a list of values into a list of string enums.
60
+ */
61
+ function asStringEnum(values: unknown[]): string[] {
62
+ return uniq(
63
+ values
64
+ .filter((v) => v !== undefined && v !== null)
65
+ .map((v) => String(v))
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Generate a minimal OpenAPI document for the mock spec.
71
+ */
72
+ export function generateOpenApi(spec: MockSpecInferSchema, serverUrl: string): OpenApi {
73
+ const paths: Record<string, Record<string, OpenApiOperation>> = {};
74
+
75
+ for (const ep of spec.endpoints) {
76
+ const oasPath = toOpenApiPath(ep.path);
77
+ const method = ep.method.toLowerCase();
78
+
79
+ const pathParams = extractPathParams(ep.path);
80
+
81
+ // Collect query match keys/values across endpoint + variants
82
+ const queryMatchValues: Record<string, string[]> = {};
83
+ const bodyMatchKeys: Set<string> = new Set();
84
+
85
+ /**
86
+ * Collect query match values into a set for documentation.
87
+ */
88
+ const collectQuery = (obj?: Record<string, string | number | boolean>) => {
89
+ if (!obj) return;
90
+ for (const [k, v] of Object.entries(obj)) {
91
+ if (!queryMatchValues[k]) queryMatchValues[k] = [];
92
+ queryMatchValues[k].push(String(v));
93
+ }
94
+ };
95
+
96
+ /**
97
+ * Collect body match keys for request body documentation.
98
+ */
99
+ const collectBody = (obj?: Record<string, string | number | boolean>) => {
100
+ if (!obj) return;
101
+ for (const k of Object.keys(obj)) bodyMatchKeys.add(k);
102
+ };
103
+
104
+ collectQuery(ep.match?.query);
105
+ collectBody(ep.match?.body);
106
+
107
+ if (ep.variants?.length) {
108
+ for (const v of ep.variants) {
109
+ collectQuery(v.match?.query);
110
+ collectBody(v.match?.body);
111
+ }
112
+ }
113
+
114
+ // Parameters: path params + known query keys
115
+ const parameters: OpenApiParameter[] = [];
116
+
117
+ for (const p of pathParams) {
118
+ parameters.push({
119
+ name: p,
120
+ in: "path",
121
+ required: true,
122
+ schema: { type: "string" }
123
+ });
124
+ }
125
+
126
+ for (const [k, vals] of Object.entries(queryMatchValues)) {
127
+ const enumVals = asStringEnum(vals);
128
+ parameters.push({
129
+ name: k,
130
+ in: "query",
131
+ required: false,
132
+ schema: enumVals.length > 0 ? { type: "string", enum: enumVals } : { type: "string" },
133
+ description: "Query param used by mock matching (if configured)."
134
+ });
135
+ }
136
+
137
+ // Request body: for non-GET/DELETE, document as generic object with known keys (from match rules)
138
+ const hasRequestBody = ep.method !== "GET" && ep.method !== "DELETE";
139
+ const requestBody: OpenApiRequestBody | undefined =
140
+ hasRequestBody && bodyMatchKeys.size > 0
141
+ ? {
142
+ required: false,
143
+ content: {
144
+ "application/json": {
145
+ schema: {
146
+ type: "object",
147
+ properties: Object.fromEntries([...bodyMatchKeys].map((k) => [k, { type: "string" }]))
148
+ }
149
+ }
150
+ }
151
+ }
152
+ : undefined;
153
+
154
+ // Responses: base + variants (grouped per status)
155
+ const responses: Record<string, OpenApiResponse> = {};
156
+
157
+ /**
158
+ * Add a response example to the OpenAPI response map.
159
+ */
160
+ const addResponseExample = (status: number, name: string, example: unknown) => {
161
+ const key = String(status);
162
+ if (!responses[key]) {
163
+ responses[key] = {
164
+ description: "Mock response",
165
+ content: { "application/json": { examples: {} as Record<string, { value: unknown }> } }
166
+ };
167
+ }
168
+ const examples = responses[key].content["application/json"].examples as Record<string, { value: unknown }>;
169
+ examples[name] = { value: example };
170
+ };
171
+
172
+ // Base response
173
+ addResponseExample(ep.status ?? 200, "default", ep.response);
174
+
175
+ // Variant responses
176
+ if (ep.variants?.length) {
177
+ for (const v of ep.variants) {
178
+ addResponseExample(v.status ?? ep.status ?? 200, v.name ?? "variant", v.response);
179
+ }
180
+ }
181
+
182
+ const operation: OpenApiOperation = {
183
+ summary: `Mock ${ep.method} ${ep.path}`,
184
+ parameters: parameters.length ? parameters : undefined,
185
+ requestBody,
186
+ responses
187
+ };
188
+
189
+ if (!paths[oasPath]) paths[oasPath] = {};
190
+ paths[oasPath][method] = operation;
191
+ }
192
+
193
+ return {
194
+ openapi: "3.0.3",
195
+ info: {
196
+ title: "mockserve",
197
+ version: "0.1.0",
198
+ description: "OpenAPI document generated from mockserve JSON spec."
199
+ },
200
+ servers: [{ url: serverUrl }],
201
+ paths
202
+ };
203
+ }
@@ -1,183 +1,115 @@
1
- import type { FastifyInstance, FastifyRequest } from 'fastify'
2
- import type { MockSpecInferSchema, EndpointSpecInferSchema } from './spec.js'
3
- import { renderTemplate } from './template.js';
4
-
5
-
6
- function normalizeMethod(method: EndpointSpecInferSchema["method"]): Lowercase<EndpointSpecInferSchema["method"]> {
7
- return method.toLowerCase() as Lowercase<EndpointSpecInferSchema["method"]>;
8
- }
9
-
10
- function sleep(ms: number): Promise<void> {
11
- return new Promise((resolve) => setTimeout(resolve, ms));
12
- }
13
-
14
- function shouldFail(errorRate: number): boolean {
15
- if (errorRate <= 0) return false;
16
- if (errorRate >= 1) return true;
17
- return Math.random() < errorRate;
18
- }
19
-
20
-
21
- function resolveBehavior(spec: MockSpecInferSchema, endpoint: EndpointSpecInferSchema) {
22
- const settings = spec.settings;
23
-
24
- const delayMs = endpoint.delayMs ?? settings.delayMs;
25
- const errorRate = endpoint.errorRate ?? settings.errorRate;
26
-
27
- const errorStatus = endpoint.errorStatus ?? settings.errorStatus;
28
- const errorResponse = endpoint.errorResponse ?? settings.errorResponse;
29
-
30
- return { delayMs, errorRate, errorStatus, errorResponse };
31
- }
32
-
33
-
34
- function asRecord(value: unknown): Record<string, unknown> {
35
- if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
36
- return {};
37
- }
38
-
39
- function queryMatches(req: FastifyRequest, endpoint: EndpointSpecInferSchema): boolean {
40
- const required = endpoint.match?.query;
41
- if (!required) return true;
42
-
43
- const q = asRecord(req.query);
44
-
45
- for (const [key, expected] of Object.entries(required)) {
46
- const actual = q[key];
47
-
48
- if (Array.isArray(actual)) return false;
49
-
50
- if (String(actual ?? "") !== String(expected)) return false;
51
- }
52
-
53
- return true;
54
- }
55
-
56
- function bodyMatches(req: FastifyRequest, expected?: Record<string, string | number | boolean>): boolean {
57
- if (!expected) return true;
58
-
59
- const b = req.body;
60
- if (!b || typeof b !== "object" || Array.isArray(b)) return false;
61
-
62
- const body = b as Record<string, unknown>;
63
-
64
- for (const [key, exp] of Object.entries(expected)) {
65
- const actual = body[key];
66
- if (String(actual ?? "") !== String(exp)) return false;
67
- }
68
- return true;
69
- }
70
-
71
- function matchRequest(req: FastifyRequest, match?: { query?: Record<string, any>; body?: Record<string, any> }): boolean {
72
- if (!match) return true;
73
-
74
- // query exact match
75
- const requiredQuery = match.query;
76
- if (requiredQuery) {
77
- const q = asRecord(req.query);
78
- for (const [key, expected] of Object.entries(requiredQuery)) {
79
- const actual = q[key];
80
- if (Array.isArray(actual)) return false;
81
- if (String(actual ?? "") !== String(expected)) return false;
1
+ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
2
+ import type { MockSpecInferSchema, EndpointSpecInferSchema, TemplateValue } from "./spec.js";
3
+ import { matchRequest, toRecord } from "./requestMatch.js";
4
+ import { createRenderContext, renderTemplateValue } from "./responseRenderer.js";
5
+ import { resolveBehavior, shouldFail, sleep, resolveDelay, type BehaviorOverrides, type DelayConfig } from "./behavior.js";
6
+ import { logEndpointRegistered } from "./logger/customLogger.js";
7
+
8
+ type ResponseSource = {
9
+ status?: number;
10
+ response: TemplateValue;
11
+ headers?: Record<string, string>;
12
+ delay?: DelayConfig;
13
+ } & BehaviorOverrides;
14
+
15
+ /**
16
+ * Select a response source from the first matching variant.
17
+ */
18
+ function selectVariant(req: FastifyRequest, endpoint: EndpointSpecInferSchema): ResponseSource | null {
19
+ if (!endpoint.variants || endpoint.variants.length === 0) return null;
20
+ for (const variant of endpoint.variants) {
21
+ if (matchRequest(req, variant.match)) {
22
+ return {
23
+ status: variant.status,
24
+ response: variant.response,
25
+ headers: variant.headers,
26
+ delay: variant.delay,
27
+ delayMs: variant.delayMs,
28
+ errorRate: variant.errorRate,
29
+ errorStatus: variant.errorStatus,
30
+ errorResponse: variant.errorResponse
31
+ };
82
32
  }
83
33
  }
84
-
85
- // body exact match (top-level)
86
- if (!bodyMatches(req, match.body)) return false;
87
-
88
- return true;
34
+ return null;
89
35
  }
90
36
 
37
+ /**
38
+ * Build a response source from the endpoint itself.
39
+ */
40
+ function selectEndpointSource(endpoint: EndpointSpecInferSchema): ResponseSource {
41
+ return {
42
+ status: endpoint.status,
43
+ response: endpoint.response,
44
+ headers: endpoint.headers,
45
+ delay: endpoint.delay,
46
+ delayMs: endpoint.delayMs,
47
+ errorRate: endpoint.errorRate,
48
+ errorStatus: endpoint.errorStatus,
49
+ errorResponse: endpoint.errorResponse
50
+ };
51
+ }
91
52
 
53
+ /**
54
+ * Register all endpoints defined in a mock spec.
55
+ */
92
56
  export function registerEndpoints(app: FastifyInstance, spec: MockSpecInferSchema): void {
93
57
  for (const endpoint of spec.endpoints) {
94
58
  app.route({
95
59
  method: endpoint.method,
96
60
  url: endpoint.path,
97
- handler: async (req, reply) => {
98
- // Decide which "response source" to use: variant or base endpoint
99
- let chosen:
100
- | {
101
- status?: number;
102
- response: unknown;
103
- delayMs?: number;
104
- errorRate?: number;
105
- errorStatus?: number;
106
- errorResponse?: unknown;
107
- }
108
- | null = null;
109
-
110
- // 1) Try variants first (first match wins)
111
- if (endpoint.variants && endpoint.variants.length > 0) {
112
- for (const v of endpoint.variants) {
113
- if (matchRequest(req, v.match)) {
114
- chosen = {
115
- status: v.status,
116
- response: v.response,
117
- delayMs: v.delayMs,
118
- errorRate: v.errorRate,
119
- errorStatus: v.errorStatus,
120
- errorResponse: v.errorResponse
121
- };
122
- break;
123
- }
124
- }
125
- }
126
-
127
- // 2) If no variant matched, fall back to endpoint-level match/response
128
- if (!chosen) {
129
- // Backward compatible: if your endpoint only has query match, this still works
130
- // If you've upgraded schema to endpoint.match (query/body), this uses it
131
- // If you haven't, you can replace endpoint.match with: { query: endpoint.match?.query }
132
- const endpointMatch = (endpoint as any).match ?? { query: (endpoint as any).match?.query };
133
-
134
- if (!matchRequest(req, endpointMatch)) {
135
- reply.code(404);
136
- return { error: "No matching mock for request" };
137
- }
61
+ handler: buildEndpointHandler(spec, endpoint)
62
+ });
138
63
 
139
- chosen = {
140
- status: endpoint.status,
141
- response: endpoint.response,
142
- delayMs: (endpoint as any).delayMs,
143
- errorRate: (endpoint as any).errorRate,
144
- errorStatus: (endpoint as any).errorStatus,
145
- errorResponse: (endpoint as any).errorResponse
146
- };
147
- }
64
+ logEndpointRegistered(app.log, endpoint.method, endpoint.path, endpoint.status);
65
+ }
66
+ }
148
67
 
149
- // 3) Resolve behavior: chosen overrides -> endpoint -> global settings
150
- const settings = spec.settings;
68
+ /**
69
+ * Build a Fastify handler for a single endpoint definition.
70
+ */
71
+ function buildEndpointHandler(spec: MockSpecInferSchema, endpoint: EndpointSpecInferSchema) {
72
+ return async (req: FastifyRequest, reply: FastifyReply) => {
73
+ const variant = selectVariant(req, endpoint);
151
74
 
152
- const delayMs = chosen.delayMs ?? (endpoint as any).delayMs ?? settings.delayMs;
153
- const errorRate = chosen.errorRate ?? (endpoint as any).errorRate ?? settings.errorRate;
154
- const errorStatus = chosen.errorStatus ?? (endpoint as any).errorStatus ?? settings.errorStatus;
155
- const errorResponse = chosen.errorResponse ?? (endpoint as any).errorResponse ?? settings.errorResponse;
75
+ if (!variant && !matchRequest(req, endpoint.match)) {
76
+ reply.code(404);
77
+ return { error: "No matching mock for request" };
78
+ }
156
79
 
157
- if (delayMs > 0) {
158
- await sleep(delayMs);
159
- }
80
+ const source = variant ?? selectEndpointSource(endpoint);
81
+ const behavior = resolveBehavior(spec.settings, endpoint, source);
160
82
 
161
- if (shouldFail(errorRate)) {
162
- reply.code(errorStatus);
163
- return errorResponse;
164
- }
83
+ // Resolve delay (supports both delay and delayMs, with delay taking precedence)
84
+ const delayValue = source.delay ? resolveDelay(source.delay) : behavior.delayMs;
85
+ if (delayValue > 0) {
86
+ await sleep(delayValue);
87
+ }
165
88
 
166
- // 4) Template rendering using request context
167
- const params = asRecord(req.params);
168
- const query = asRecord(req.query);
169
- const body = req.body;
89
+ const params = toRecord(req.params);
90
+ const query = toRecord(req.query);
91
+ const body = req.body;
92
+ const renderContext = createRenderContext({ params, query, body }, spec.settings.fakerSeed);
170
93
 
171
- const rendered = renderTemplate(chosen.response, { params, query, body });
94
+ if (shouldFail(behavior.errorRate)) {
95
+ reply.code(behavior.errorStatus);
96
+ return renderTemplateValue(behavior.errorResponse, renderContext);
97
+ }
172
98
 
173
- // 5) Status code precedence: chosen -> endpoint -> 200
174
- reply.code(chosen.status ?? endpoint.status ?? 200);
175
- return rendered;
99
+ // Apply custom headers (with template support)
100
+ const headers = source.headers ?? endpoint.headers;
101
+ if (headers) {
102
+ for (const [key, value] of Object.entries(headers)) {
103
+ const renderedValue = typeof value === "string"
104
+ ? String(renderTemplateValue(value, renderContext))
105
+ : String(value);
106
+ reply.header(key, renderedValue);
176
107
  }
177
- });
108
+ }
178
109
 
179
- app.log.info(
180
- `Registered ${endpoint.method} ${endpoint.path} -> ${endpoint.status} (delay=${endpoint.delayMs ?? spec.settings.delayMs}ms, errorRate=${endpoint.errorRate ?? spec.settings.errorRate})`
181
- );
182
- }
110
+ const rendered = renderTemplateValue(source.response, renderContext);
111
+
112
+ reply.code(source.status ?? endpoint.status ?? 200);
113
+ return rendered;
114
+ };
183
115
  }