api-json-server 1.0.1
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/dist/index.js +21 -0
- package/mock.spec.json +65 -0
- package/package.json +28 -0
- package/src/index.ts +122 -0
- package/src/loadSpec.ts +28 -0
- package/src/registerEndpoints.ts +183 -0
- package/src/server.ts +29 -0
- package/src/spec.ts +58 -0
- package/src/template.ts +61 -0
- package/tsconfig.json +13 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const commander_1 = require("commander");
|
|
5
|
+
const program = new commander_1.Command();
|
|
6
|
+
program
|
|
7
|
+
.name("mockserve")
|
|
8
|
+
.description("A simple API Mock Server driven by a JSON spec file.")
|
|
9
|
+
.version("0.1.0");
|
|
10
|
+
program
|
|
11
|
+
.command("serve")
|
|
12
|
+
.description("Start the mock server.")
|
|
13
|
+
.option("-p, --port <number>", "Port to run the server on", "3000")
|
|
14
|
+
.option("-s, --spec <path>", "Path to the spec JSON file", "mock.spec.json")
|
|
15
|
+
.action((opts) => {
|
|
16
|
+
console.log("mockserve serve called with:");
|
|
17
|
+
console.log(`- port: ${opts.port}`);
|
|
18
|
+
console.log(`- spec: ${opts.spec}`);
|
|
19
|
+
console.log("Server not implemented yet.");
|
|
20
|
+
});
|
|
21
|
+
program.parse(process.argv);
|
package/mock.spec.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"settings": {
|
|
4
|
+
"delayMs": 0,
|
|
5
|
+
"errorRate": 0,
|
|
6
|
+
"errorStatus": 503,
|
|
7
|
+
"errorResponse": { "error": "Service temporarily unavailable" }
|
|
8
|
+
},
|
|
9
|
+
"endpoints": [
|
|
10
|
+
{
|
|
11
|
+
"method": "GET",
|
|
12
|
+
"path": "/users/:id",
|
|
13
|
+
"status": 200,
|
|
14
|
+
"response": {
|
|
15
|
+
"id": "{{params.id}}",
|
|
16
|
+
"type": "{{query.type}}",
|
|
17
|
+
"message": "User lookup ok"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"method": "GET",
|
|
22
|
+
"path": "/search",
|
|
23
|
+
"status": 200,
|
|
24
|
+
"match": { "query": { "type": "premium" } },
|
|
25
|
+
"response": {
|
|
26
|
+
"ok": true,
|
|
27
|
+
"filter": "{{query.type}}",
|
|
28
|
+
"results": [
|
|
29
|
+
{ "id": "u1", "tier": "premium" },
|
|
30
|
+
{ "id": "u2", "tier": "premium" }
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"method": "POST",
|
|
36
|
+
"path": "/echo",
|
|
37
|
+
"status": 201,
|
|
38
|
+
"response": {
|
|
39
|
+
"receivedEmail": "{{body.email}}",
|
|
40
|
+
"receivedName": "{{body.name}}",
|
|
41
|
+
"rawBody": "{{body}}"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"method": "POST",
|
|
46
|
+
"path": "/login",
|
|
47
|
+
"status": 200,
|
|
48
|
+
"response": { "ok": true, "message": "Default login response" },
|
|
49
|
+
"variants": [
|
|
50
|
+
{
|
|
51
|
+
"name": "invalid password",
|
|
52
|
+
"match": { "body": { "password": "wrong" } },
|
|
53
|
+
"status": 401,
|
|
54
|
+
"response": { "ok": false, "error": "Invalid credentials" }
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"name": "admin login",
|
|
58
|
+
"match": { "body": { "email": "admin@example.com" } },
|
|
59
|
+
"status": 200,
|
|
60
|
+
"response": { "ok": true, "role": "admin", "token": "tok_{{body.email}}" }
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "api-json-server",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "tsx src/index.ts",
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"start": "node dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"mockserve": "dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [],
|
|
15
|
+
"author": "",
|
|
16
|
+
"license": "ISC",
|
|
17
|
+
"type": "commonjs",
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^25.0.9",
|
|
20
|
+
"tsx": "^4.21.0",
|
|
21
|
+
"typescript": "^5.9.3"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"commander": "^14.0.2",
|
|
25
|
+
"fastify": "^5.7.1",
|
|
26
|
+
"zod": "^4.3.5"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { watch } from "node:fs";
|
|
5
|
+
|
|
6
|
+
const program = new Command();
|
|
7
|
+
|
|
8
|
+
program
|
|
9
|
+
.name("mockserve")
|
|
10
|
+
.description("A simple API Mock Server driven by a JSON spec file.")
|
|
11
|
+
.version("0.1.0");
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.command("serve")
|
|
15
|
+
.description("Start the mock server.")
|
|
16
|
+
.option("-p, --port <number>", "Port to run the server on", "3000")
|
|
17
|
+
.option("-s, --spec <path>", "Path to the spec JSON file", "mock.spec.json")
|
|
18
|
+
.option("--watch", "Reload when spec file changes", true)
|
|
19
|
+
.option("--no-watch", "Disable reload when spec file changes")
|
|
20
|
+
.action(async (opts: { port: string; spec: string, watch: boolean }) => {
|
|
21
|
+
const port = Number(opts.port);
|
|
22
|
+
|
|
23
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
24
|
+
console.error(`Invalid port: ${opts.port}`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const specPath = opts.spec;
|
|
29
|
+
|
|
30
|
+
const { loadSpecFromFile } = await import("./loadSpec.js");
|
|
31
|
+
const { buildServer } = await import("./server.js");
|
|
32
|
+
|
|
33
|
+
let app = null as null | import("fastify").FastifyInstance;
|
|
34
|
+
let isReloading = false;
|
|
35
|
+
let debounceTimer: NodeJS.Timeout | null = null;
|
|
36
|
+
|
|
37
|
+
async function startWithSpec() {
|
|
38
|
+
const loadedAt = new Date().toISOString();
|
|
39
|
+
|
|
40
|
+
const spec = await loadSpecFromFile(specPath);
|
|
41
|
+
console.log(`Loaded spec v${spec.version} with ${spec.endpoints.length} endpoint(s).`);
|
|
42
|
+
|
|
43
|
+
const nextApp = buildServer(spec, { specPath, loadedAt });
|
|
44
|
+
try {
|
|
45
|
+
await nextApp.listen({ port, host: "0.0.0.0" });
|
|
46
|
+
} catch (err) {
|
|
47
|
+
nextApp.log.error(err);
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
nextApp.log.info(`Mock server running on http://localhost:${port}`);
|
|
52
|
+
nextApp.log.info(`Spec: ${specPath} (loadedAt=${loadedAt})`);
|
|
53
|
+
|
|
54
|
+
return nextApp;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function reload() {
|
|
58
|
+
if (isReloading) return;
|
|
59
|
+
isReloading = true;
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
console.log("Reloading spec...");
|
|
63
|
+
|
|
64
|
+
// 1) Stop accepting requests on the old server FIRST
|
|
65
|
+
if (app) {
|
|
66
|
+
console.log("Closing current server...");
|
|
67
|
+
await app.close();
|
|
68
|
+
console.log("Current server closed.");
|
|
69
|
+
app = null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 2) Start a new server on the same port with the updated spec
|
|
73
|
+
app = await startWithSpec();
|
|
74
|
+
|
|
75
|
+
console.log("Reload complete.");
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.error("Reload failed.");
|
|
78
|
+
|
|
79
|
+
// At this point the old server may already be closed. We want visibility.
|
|
80
|
+
console.error(String(err));
|
|
81
|
+
|
|
82
|
+
// Optional: try to start again to avoid being down
|
|
83
|
+
try {
|
|
84
|
+
if (!app) {
|
|
85
|
+
console.log("Attempting to start server again after reload failure...");
|
|
86
|
+
app = await startWithSpec();
|
|
87
|
+
console.log("Recovery start succeeded.");
|
|
88
|
+
}
|
|
89
|
+
} catch (err2) {
|
|
90
|
+
console.error("Recovery start failed. Server is down until next successful reload.");
|
|
91
|
+
console.error(String(err2));
|
|
92
|
+
}
|
|
93
|
+
} finally {
|
|
94
|
+
isReloading = false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Initial start
|
|
99
|
+
try {
|
|
100
|
+
app = await startWithSpec();
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error(String(err));
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Watch spec for changes
|
|
107
|
+
if (opts.watch) {
|
|
108
|
+
console.log(`Watching spec file for changes: ${specPath}`);
|
|
109
|
+
|
|
110
|
+
// fs.watch emits multiple events; debounce to avoid rapid reload loops
|
|
111
|
+
watch(specPath, () => {
|
|
112
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
113
|
+
debounceTimer = setTimeout(() => {
|
|
114
|
+
void reload();
|
|
115
|
+
}, 200);
|
|
116
|
+
});
|
|
117
|
+
} else {
|
|
118
|
+
console.log("Watch disabled (--no-watch).");
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
program.parse(process.argv);
|
package/src/loadSpec.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { MockSpecSchema, type MockSpecInferSchema } from './spec.js'
|
|
3
|
+
|
|
4
|
+
export async function loadSpecFromFile(specPath: string): Promise<MockSpecInferSchema> {
|
|
5
|
+
let raw: string
|
|
6
|
+
try {
|
|
7
|
+
raw = await readFile(specPath, 'utf-8')
|
|
8
|
+
} catch (err) {
|
|
9
|
+
throw new Error(`Failed to read spec file ${specPath}: ${err instanceof Error ? err.message : String(err)}`)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let json: unknown
|
|
13
|
+
try {
|
|
14
|
+
json = JSON.parse(raw)
|
|
15
|
+
} catch (err) {
|
|
16
|
+
throw new Error(`Failed to parse spec file ${specPath}: ${err instanceof Error ? err.message : String(err)}`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const parsed = MockSpecSchema.safeParse(json)
|
|
20
|
+
if (!parsed.success) {
|
|
21
|
+
const issues = parsed.error.issues
|
|
22
|
+
.map((i) => `- ${i.path.join(".") || "(root)"}: ${i.message}`)
|
|
23
|
+
.join("\n")
|
|
24
|
+
throw new Error(`Invalid spec file ${specPath}: ${issues}`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return parsed.data
|
|
28
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
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;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// body exact match (top-level)
|
|
86
|
+
if (!bodyMatches(req, match.body)) return false;
|
|
87
|
+
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
export function registerEndpoints(app: FastifyInstance, spec: MockSpecInferSchema): void {
|
|
93
|
+
for (const endpoint of spec.endpoints) {
|
|
94
|
+
app.route({
|
|
95
|
+
method: endpoint.method,
|
|
96
|
+
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
|
+
}
|
|
138
|
+
|
|
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
|
+
}
|
|
148
|
+
|
|
149
|
+
// 3) Resolve behavior: chosen overrides -> endpoint -> global settings
|
|
150
|
+
const settings = spec.settings;
|
|
151
|
+
|
|
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;
|
|
156
|
+
|
|
157
|
+
if (delayMs > 0) {
|
|
158
|
+
await sleep(delayMs);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (shouldFail(errorRate)) {
|
|
162
|
+
reply.code(errorStatus);
|
|
163
|
+
return errorResponse;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 4) Template rendering using request context
|
|
167
|
+
const params = asRecord(req.params);
|
|
168
|
+
const query = asRecord(req.query);
|
|
169
|
+
const body = req.body;
|
|
170
|
+
|
|
171
|
+
const rendered = renderTemplate(chosen.response, { params, query, body });
|
|
172
|
+
|
|
173
|
+
// 5) Status code precedence: chosen -> endpoint -> 200
|
|
174
|
+
reply.code(chosen.status ?? endpoint.status ?? 200);
|
|
175
|
+
return rendered;
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
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
|
+
}
|
|
183
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import Fastify, { FastifyInstance } from "fastify";
|
|
2
|
+
import { MockSpecInferSchema } from "./spec.js";
|
|
3
|
+
import { registerEndpoints } from "./registerEndpoints.js";
|
|
4
|
+
|
|
5
|
+
export function buildServer(spec: MockSpecInferSchema, meta?: { specPath?: string; loadedAt?: string }): FastifyInstance {
|
|
6
|
+
const app = Fastify({
|
|
7
|
+
logger: true
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// Basic sanity route
|
|
11
|
+
app.get("/health", async () => {
|
|
12
|
+
return { ok: true };
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// 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
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
registerEndpoints(app, spec);
|
|
27
|
+
|
|
28
|
+
return app;
|
|
29
|
+
}
|
package/src/spec.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as z from 'zod'
|
|
2
|
+
|
|
3
|
+
const Primitive = z.union([z.string(), z.number(), z.boolean()]);
|
|
4
|
+
|
|
5
|
+
export const MatchSchema = z.object({
|
|
6
|
+
query: z.record(z.string(), Primitive).optional(),
|
|
7
|
+
// Exact match for top-level body fields only (keeps v1 simple)
|
|
8
|
+
body: z.record(z.string(), Primitive).optional()
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const VariantSchema = z.object({
|
|
12
|
+
name: z.string().min(1).optional(),
|
|
13
|
+
match: MatchSchema.optional(),
|
|
14
|
+
|
|
15
|
+
status: z.number().int().min(100).max(599).optional(),
|
|
16
|
+
response: z.unknown(),
|
|
17
|
+
|
|
18
|
+
// Simulation overrides per variant (optional)
|
|
19
|
+
delayMs: z.number().int().min(0).optional(),
|
|
20
|
+
errorRate: z.number().min(0).max(1).optional(),
|
|
21
|
+
errorStatus: z.number().int().min(100).max(599).optional(),
|
|
22
|
+
errorResponse: z.unknown().optional()
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const EndpointSchema = z.object({
|
|
26
|
+
method: z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']),
|
|
27
|
+
path: z.string().min(1),
|
|
28
|
+
|
|
29
|
+
match: MatchSchema.optional(),
|
|
30
|
+
variants: z.array(VariantSchema).min(1).optional(),
|
|
31
|
+
|
|
32
|
+
// Response behavior:
|
|
33
|
+
status: z.number().int().min(200).max(599).default(200),
|
|
34
|
+
response: z.unknown(),
|
|
35
|
+
|
|
36
|
+
// Simulation (optional overrides)
|
|
37
|
+
delayMs: z.number().int().min(0).optional(),
|
|
38
|
+
errorRate: z.number().min(0).max(1).optional(),
|
|
39
|
+
errorStatus: z.number().int().min(100).max(599).optional(),
|
|
40
|
+
errorResponse: z.unknown().optional()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
export const MockSpecSchema = z.object({
|
|
44
|
+
version: z.literal(1),
|
|
45
|
+
settings: z
|
|
46
|
+
.object({
|
|
47
|
+
delayMs: z.number().int().min(0).default(0),
|
|
48
|
+
errorRate: z.number().min(0).max(1).default(0),
|
|
49
|
+
errorStatus: z.number().int().min(100).max(599).default(500),
|
|
50
|
+
errorResponse: z.unknown().default({ error: "Mock error" })
|
|
51
|
+
})
|
|
52
|
+
.default({ delayMs: 0, errorRate: 0, errorStatus: 500, errorResponse: { error: "Mock error" } }),
|
|
53
|
+
endpoints: z.array(EndpointSchema).min(1)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
export type MockSpecInferSchema = z.infer<typeof MockSpecSchema>;
|
|
57
|
+
export type EndpointSpecInferSchema = z.infer<typeof EndpointSchema>;
|
|
58
|
+
export type VariantSpecInferSchema = z.infer<typeof VariantSchema>;
|
package/src/template.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
type TemplateContext = {
|
|
2
|
+
params: Record<string, unknown>;
|
|
3
|
+
query: Record<string, unknown>;
|
|
4
|
+
body: unknown;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
function getPath(obj: unknown, path: string): unknown {
|
|
8
|
+
if (!path) return undefined;
|
|
9
|
+
|
|
10
|
+
const parts = path.split(".");
|
|
11
|
+
let cur: any = obj;
|
|
12
|
+
|
|
13
|
+
for (const p of parts) {
|
|
14
|
+
if (cur == null) return undefined;
|
|
15
|
+
cur = cur[p];
|
|
16
|
+
}
|
|
17
|
+
return cur;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderStringTemplate(input: string, ctx: TemplateContext): string {
|
|
21
|
+
// Replaces occurrences like {{params.id}} or {{query.type}} or {{body.email}}
|
|
22
|
+
return input.replace(/\{\{\s*([a-zA-Z]+)\.([a-zA-Z0-9_.]+)\s*\}\}/g, (_m, root, path) => {
|
|
23
|
+
let source: unknown;
|
|
24
|
+
if (root === "params") source = ctx.params;
|
|
25
|
+
else if (root === "query") source = ctx.query;
|
|
26
|
+
else if (root === "body") source = ctx.body;
|
|
27
|
+
else return "";
|
|
28
|
+
|
|
29
|
+
const value = root === "body" ? getPath(source, path) : getPath(source, path);
|
|
30
|
+
|
|
31
|
+
if (value === undefined || value === null) return "";
|
|
32
|
+
if (typeof value === "string") return value;
|
|
33
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
34
|
+
|
|
35
|
+
// For objects/arrays, stringify to keep output valid (still a string substitution)
|
|
36
|
+
try {
|
|
37
|
+
return JSON.stringify(value);
|
|
38
|
+
} catch {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function renderTemplate(value: unknown, ctx: TemplateContext): unknown {
|
|
45
|
+
if (typeof value === "string") return renderStringTemplate(value, ctx);
|
|
46
|
+
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
return value.map((v) => renderTemplate(v, ctx));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (value && typeof value === "object") {
|
|
52
|
+
const out: Record<string, unknown> = {};
|
|
53
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
54
|
+
out[k] = renderTemplate(v, ctx);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// numbers, booleans, null, undefined stay as-is
|
|
60
|
+
return value;
|
|
61
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"outDir": "dist"
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*"]
|
|
13
|
+
}
|