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,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
|
-
|
|
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
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
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",
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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(),
|
|
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(),
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
+
}
|