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.
- package/README.md +636 -0
- package/dist/behavior.js +44 -0
- package/dist/history/historyRecorder.js +66 -0
- package/dist/history/types.js +2 -0
- package/dist/index.js +126 -5
- package/dist/loadSpec.js +32 -0
- package/dist/logger/customLogger.js +75 -0
- package/dist/logger/formatters.js +82 -0
- package/dist/logger/types.js +2 -0
- package/dist/openapi.js +152 -0
- package/dist/registerEndpoints.js +97 -0
- package/dist/requestMatch.js +99 -0
- package/dist/responseRenderer.js +98 -0
- package/dist/server.js +210 -0
- package/dist/spec.js +146 -0
- package/dist/stringTemplate.js +55 -0
- package/examples/auth-variants.json +31 -0
- package/examples/basic-crud.json +46 -0
- package/examples/companies-nested.json +47 -0
- package/examples/orders-and-matches.json +49 -0
- package/examples/users-faker.json +35 -0
- package/mock.spec.json +1 -0
- package/mockserve.spec.schema.json +7 -0
- package/package.json +20 -3
- package/scripts/build-schema.ts +21 -0
- package/src/behavior.ts +56 -0
- package/src/history/historyRecorder.ts +77 -0
- package/src/history/types.ts +25 -0
- package/src/index.ts +124 -85
- package/src/loadSpec.ts +5 -2
- package/src/logger/customLogger.ts +85 -0
- package/src/logger/formatters.ts +74 -0
- package/src/logger/types.ts +30 -0
- package/src/openapi.ts +203 -0
- package/src/registerEndpoints.ts +94 -162
- package/src/requestMatch.ts +104 -0
- package/src/responseRenderer.ts +112 -0
- package/src/server.ts +236 -14
- package/src/spec.ts +108 -8
- package/src/stringTemplate.ts +55 -0
- package/tests/behavior.test.ts +88 -0
- package/tests/cors.test.ts +128 -0
- package/tests/faker.test.ts +175 -0
- package/tests/fixtures/spec.basic.json +39 -0
- package/tests/headers.test.ts +124 -0
- package/tests/helpers.ts +28 -0
- package/tests/history.test.ts +188 -0
- package/tests/matching.test.ts +245 -0
- package/tests/server.test.ts +73 -0
- package/tests/template.test.ts +90 -0
- 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
|
+
}
|
package/src/registerEndpoints.ts
CHANGED
|
@@ -1,183 +1,115 @@
|
|
|
1
|
-
import type { FastifyInstance, FastifyRequest } from
|
|
2
|
-
import type { MockSpecInferSchema, EndpointSpecInferSchema } from
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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:
|
|
98
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
75
|
+
if (!variant && !matchRequest(req, endpoint.match)) {
|
|
76
|
+
reply.code(404);
|
|
77
|
+
return { error: "No matching mock for request" };
|
|
78
|
+
}
|
|
156
79
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
80
|
+
const source = variant ?? selectEndpointSource(endpoint);
|
|
81
|
+
const behavior = resolveBehavior(spec.settings, endpoint, source);
|
|
160
82
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
94
|
+
if (shouldFail(behavior.errorRate)) {
|
|
95
|
+
reply.code(behavior.errorStatus);
|
|
96
|
+
return renderTemplateValue(behavior.errorResponse, renderContext);
|
|
97
|
+
}
|
|
172
98
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
);
|
|
182
|
-
|
|
110
|
+
const rendered = renderTemplateValue(source.response, renderContext);
|
|
111
|
+
|
|
112
|
+
reply.code(source.status ?? endpoint.status ?? 200);
|
|
113
|
+
return rendered;
|
|
114
|
+
};
|
|
183
115
|
}
|