api-json-server 1.0.1 → 1.1.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 +286 -0
- package/dist/behavior.js +32 -0
- package/dist/index.js +115 -5
- package/dist/loadSpec.js +32 -0
- package/dist/openapi.js +152 -0
- package/dist/registerEndpoints.js +80 -0
- package/dist/requestMatch.js +60 -0
- package/dist/responseRenderer.js +98 -0
- package/dist/server.js +150 -0
- package/dist/spec.js +110 -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 +15 -3
- package/scripts/build-schema.ts +21 -0
- package/src/behavior.ts +42 -0
- package/src/index.ts +108 -82
- package/src/loadSpec.ts +5 -2
- package/src/openapi.ts +203 -0
- package/src/registerEndpoints.ts +77 -163
- package/src/requestMatch.ts +62 -0
- package/src/responseRenderer.ts +112 -0
- package/src/server.ts +164 -15
- package/src/spec.ts +70 -8
- package/src/stringTemplate.ts +55 -0
- package/tests/behavior.test.ts +88 -0
- package/tests/faker.test.ts +175 -0
- package/tests/fixtures/spec.basic.json +39 -0
- package/tests/helpers.ts +28 -0
- package/tests/matching.test.ts +136 -0
- package/tests/server.test.ts +73 -0
- package/tests/template.test.ts +90 -0
- package/src/template.ts +0 -61
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../mockserve.spec.schema.json",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"settings": {
|
|
5
|
+
"delayMs": 0,
|
|
6
|
+
"errorRate": 0,
|
|
7
|
+
"errorStatus": 500,
|
|
8
|
+
"errorResponse": { "error": "Mock error" }
|
|
9
|
+
},
|
|
10
|
+
"endpoints": [
|
|
11
|
+
{
|
|
12
|
+
"method": "GET",
|
|
13
|
+
"path": "/orders",
|
|
14
|
+
"match": { "query": { "status": "pending" } },
|
|
15
|
+
"response": {
|
|
16
|
+
"status": "{{query.status}}",
|
|
17
|
+
"orders": {
|
|
18
|
+
"__repeat": {
|
|
19
|
+
"min": 5,
|
|
20
|
+
"max": 8,
|
|
21
|
+
"template": {
|
|
22
|
+
"id": { "__faker": "string.uuid" },
|
|
23
|
+
"total": { "__faker": { "method": "number.int", "args": [ { "min": 20, "max": 200 } ] } },
|
|
24
|
+
"placedAt": { "__faker": { "method": "date.recent", "args": [7] } }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"method": "POST",
|
|
32
|
+
"path": "/orders",
|
|
33
|
+
"status": 201,
|
|
34
|
+
"response": {
|
|
35
|
+
"id": { "__faker": "string.uuid" },
|
|
36
|
+
"priority": "{{body.priority}}",
|
|
37
|
+
"message": "Order created"
|
|
38
|
+
},
|
|
39
|
+
"variants": [
|
|
40
|
+
{
|
|
41
|
+
"name": "high priority",
|
|
42
|
+
"match": { "body": { "priority": "high" } },
|
|
43
|
+
"status": 202,
|
|
44
|
+
"response": { "queued": true, "message": "Order queued for review" }
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../mockserve.spec.schema.json",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"settings": {
|
|
5
|
+
"delayMs": 0,
|
|
6
|
+
"errorRate": 0,
|
|
7
|
+
"errorStatus": 500,
|
|
8
|
+
"errorResponse": { "error": "Mock error" },
|
|
9
|
+
"fakerSeed": 123
|
|
10
|
+
},
|
|
11
|
+
"endpoints": [
|
|
12
|
+
{
|
|
13
|
+
"method": "GET",
|
|
14
|
+
"path": "/users",
|
|
15
|
+
"response": {
|
|
16
|
+
"users": {
|
|
17
|
+
"__repeat": {
|
|
18
|
+
"min": 10,
|
|
19
|
+
"max": 15,
|
|
20
|
+
"template": {
|
|
21
|
+
"id": { "__faker": "string.uuid" },
|
|
22
|
+
"firstName": { "__faker": "person.firstName" },
|
|
23
|
+
"lastName": { "__faker": "person.lastName" },
|
|
24
|
+
"avatarUrl": { "__faker": "image.avatar" },
|
|
25
|
+
"phone": { "__faker": "phone.number" },
|
|
26
|
+
"email": { "__faker": "internet.email" },
|
|
27
|
+
"company": { "__faker": "company.name" },
|
|
28
|
+
"joinedAt": { "__faker": { "method": "date.recent", "args": [30] } }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
package/mock.spec.json
CHANGED
package/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "api-json-server",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "tsx src/index.ts",
|
|
8
8
|
"build": "tsc",
|
|
9
|
-
"start": "node dist/index.js"
|
|
9
|
+
"start": "node dist/index.js",
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"test:watch": "vitest",
|
|
12
|
+
"schema:build": "tsx scripts/build-schema.ts"
|
|
10
13
|
},
|
|
11
14
|
"bin": {
|
|
12
15
|
"mockserve": "dist/index.js"
|
|
@@ -17,12 +20,21 @@
|
|
|
17
20
|
"type": "commonjs",
|
|
18
21
|
"devDependencies": {
|
|
19
22
|
"@types/node": "^25.0.9",
|
|
23
|
+
"@types/supertest": "^6.0.3",
|
|
24
|
+
"@types/swagger-ui-dist": "^3.30.6",
|
|
25
|
+
"supertest": "^7.2.2",
|
|
20
26
|
"tsx": "^4.21.0",
|
|
21
|
-
"typescript": "^5.9.3"
|
|
27
|
+
"typescript": "^5.9.3",
|
|
28
|
+
"vitest": "^4.0.17",
|
|
29
|
+
"zod-to-json-schema": "^3.25.1"
|
|
22
30
|
},
|
|
23
31
|
"dependencies": {
|
|
32
|
+
"@faker-js/faker": "^10.2.0",
|
|
33
|
+
"@fastify/static": "^9.0.0",
|
|
24
34
|
"commander": "^14.0.2",
|
|
25
35
|
"fastify": "^5.7.1",
|
|
36
|
+
"swagger-ui-dist": "^5.31.0",
|
|
37
|
+
"yaml": "^2.8.2",
|
|
26
38
|
"zod": "^4.3.5"
|
|
27
39
|
}
|
|
28
40
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
3
|
+
import { MockSpecSchema } from "../src/spec.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build the JSON schema file from the Zod spec.
|
|
7
|
+
*/
|
|
8
|
+
async function main() {
|
|
9
|
+
const schema = MockSpecSchema as unknown as Parameters<typeof zodToJsonSchema>[0];
|
|
10
|
+
const jsonSchema = zodToJsonSchema(schema, {
|
|
11
|
+
name: "MockServeSpec"
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
await writeFile("mockserve.spec.schema.json", JSON.stringify(jsonSchema, null, 2), "utf-8");
|
|
15
|
+
console.log("Wrote mockserve.spec.schema.json");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
main().catch((err) => {
|
|
19
|
+
console.error(err);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
});
|
package/src/behavior.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { TemplateValue } from "./spec.js";
|
|
2
|
+
|
|
3
|
+
export type BehaviorSettings = {
|
|
4
|
+
delayMs: number;
|
|
5
|
+
errorRate: number;
|
|
6
|
+
errorStatus: number;
|
|
7
|
+
errorResponse: TemplateValue;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type BehaviorOverrides = Partial<BehaviorSettings>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Pause for the given number of milliseconds.
|
|
14
|
+
*/
|
|
15
|
+
export function sleep(ms: number): Promise<void> {
|
|
16
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Decide whether a request should fail based on an error rate.
|
|
21
|
+
*/
|
|
22
|
+
export function shouldFail(errorRate: number): boolean {
|
|
23
|
+
if (errorRate <= 0) return false;
|
|
24
|
+
if (errorRate >= 1) return true;
|
|
25
|
+
return Math.random() < errorRate;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve behavior settings with precedence: chosen overrides -> endpoint overrides -> global settings.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveBehavior(
|
|
32
|
+
settings: BehaviorSettings,
|
|
33
|
+
endpointOverrides?: BehaviorOverrides,
|
|
34
|
+
chosenOverrides?: BehaviorOverrides
|
|
35
|
+
): BehaviorSettings {
|
|
36
|
+
return {
|
|
37
|
+
delayMs: chosenOverrides?.delayMs ?? endpointOverrides?.delayMs ?? settings.delayMs,
|
|
38
|
+
errorRate: chosenOverrides?.errorRate ?? endpointOverrides?.errorRate ?? settings.errorRate,
|
|
39
|
+
errorStatus: chosenOverrides?.errorStatus ?? endpointOverrides?.errorStatus ?? settings.errorStatus,
|
|
40
|
+
errorResponse: chosenOverrides?.errorResponse ?? endpointOverrides?.errorResponse ?? settings.errorResponse
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,106 +17,132 @@ program
|
|
|
17
17
|
.option("-s, --spec <path>", "Path to the spec JSON file", "mock.spec.json")
|
|
18
18
|
.option("--watch", "Reload when spec file changes", true)
|
|
19
19
|
.option("--no-watch", "Disable reload when spec file changes")
|
|
20
|
-
.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
console.error(`Invalid port: ${opts.port}`);
|
|
25
|
-
process.exit(1);
|
|
26
|
-
}
|
|
20
|
+
.option("--base-url <url>", "Public base URL used in OpenAPI servers[] (e.g. https://example.com)")
|
|
21
|
+
.action(async (opts: { port: string; spec: string; watch: boolean; baseUrl?: string }) => {
|
|
22
|
+
await startCommand(opts);
|
|
23
|
+
});
|
|
27
24
|
|
|
28
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Run the mock server CLI command.
|
|
27
|
+
*/
|
|
28
|
+
async function startCommand(opts: { port: string; spec: string; watch: boolean; baseUrl?: string }): Promise<void> {
|
|
29
|
+
const port = Number(opts.port);
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
32
|
+
console.error(`Invalid port: ${opts.port}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
let isReloading = false;
|
|
35
|
-
let debounceTimer: NodeJS.Timeout | null = null;
|
|
36
|
+
const specPath = opts.spec;
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
const { loadSpecFromFile } = await import("./loadSpec.js");
|
|
39
|
+
const { buildServer } = await import("./server.js");
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
let app = null as null | import("fastify").FastifyInstance;
|
|
42
|
+
let isReloading = false;
|
|
43
|
+
let debounceTimer: NodeJS.Timeout | null = null;
|
|
42
44
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
throw err;
|
|
49
|
-
}
|
|
45
|
+
/**
|
|
46
|
+
* Build and start a server using the current spec file.
|
|
47
|
+
*/
|
|
48
|
+
async function startWithSpec() {
|
|
49
|
+
const loadedAt = new Date().toISOString();
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
const spec = await loadSpecFromFile(specPath);
|
|
52
|
+
console.log(`Loaded spec v${spec.version} with ${spec.endpoints.length} endpoint(s).`);
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
const nextApp = buildServer(spec, { specPath, loadedAt, baseUrl: opts.baseUrl });
|
|
55
|
+
try {
|
|
56
|
+
await nextApp.listen({ port, host: "0.0.0.0" });
|
|
57
|
+
} catch (err) {
|
|
58
|
+
nextApp.log.error(err);
|
|
59
|
+
throw err;
|
|
55
60
|
}
|
|
56
61
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
isReloading = true;
|
|
62
|
+
nextApp.log.info(`Mock server running on http://localhost:${port}`);
|
|
63
|
+
nextApp.log.info(`Spec: ${specPath} (loadedAt=${loadedAt})`);
|
|
60
64
|
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
}
|
|
65
|
+
return nextApp;
|
|
66
|
+
}
|
|
71
67
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
}
|
|
68
|
+
/**
|
|
69
|
+
* Reload the server when the spec changes.
|
|
70
|
+
*/
|
|
71
|
+
async function reload() {
|
|
72
|
+
if (isReloading) return;
|
|
73
|
+
isReloading = true;
|
|
97
74
|
|
|
98
|
-
// Initial start
|
|
99
75
|
try {
|
|
76
|
+
console.log("Reloading spec...");
|
|
77
|
+
|
|
78
|
+
// 1) Stop accepting requests on the old server FIRST
|
|
79
|
+
if (app) {
|
|
80
|
+
console.log("Closing current server...");
|
|
81
|
+
await app.close();
|
|
82
|
+
console.log("Current server closed.");
|
|
83
|
+
app = null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 2) Start a new server on the same port with the updated spec
|
|
100
87
|
app = await startWithSpec();
|
|
88
|
+
|
|
89
|
+
console.log("Reload complete.");
|
|
101
90
|
} catch (err) {
|
|
91
|
+
console.error("Reload failed.");
|
|
92
|
+
|
|
93
|
+
// At this point the old server may already be closed. We want visibility.
|
|
102
94
|
console.error(String(err));
|
|
103
|
-
process.exit(1);
|
|
104
|
-
}
|
|
105
95
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
96
|
+
// Optional: try to start again to avoid being down
|
|
97
|
+
try {
|
|
98
|
+
if (!app) {
|
|
99
|
+
console.log("Attempting to start server again after reload failure...");
|
|
100
|
+
app = await startWithSpec();
|
|
101
|
+
console.log("Recovery start succeeded.");
|
|
102
|
+
}
|
|
103
|
+
} catch (err2) {
|
|
104
|
+
console.error("Recovery start failed. Server is down until next successful reload.");
|
|
105
|
+
console.error(String(err2));
|
|
106
|
+
}
|
|
107
|
+
} finally {
|
|
108
|
+
isReloading = false;
|
|
119
109
|
}
|
|
120
|
-
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Initial start
|
|
113
|
+
try {
|
|
114
|
+
app = await startWithSpec();
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error(String(err));
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Watch spec for changes
|
|
121
|
+
/**
|
|
122
|
+
* Handle file changes with a debounced reload.
|
|
123
|
+
*/
|
|
124
|
+
function onSpecChange(): void {
|
|
125
|
+
debounceTimer = scheduleReload(reload, debounceTimer);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (opts.watch) {
|
|
129
|
+
console.log(`Watching spec file for changes: ${specPath}`);
|
|
130
|
+
|
|
131
|
+
// fs.watch emits multiple events; debounce to avoid rapid reload loops
|
|
132
|
+
watch(specPath, onSpecChange);
|
|
133
|
+
} else {
|
|
134
|
+
console.log("Watch disabled (--no-watch).");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Schedule a debounced reload when the spec changes.
|
|
140
|
+
*/
|
|
141
|
+
function scheduleReload(reload: () => Promise<void>, debounceTimer: NodeJS.Timeout | null): NodeJS.Timeout {
|
|
142
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
143
|
+
return setTimeout(() => {
|
|
144
|
+
void reload();
|
|
145
|
+
}, 200);
|
|
146
|
+
}
|
|
121
147
|
|
|
122
148
|
program.parse(process.argv);
|
package/src/loadSpec.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { readFile } from
|
|
2
|
-
import { MockSpecSchema, type MockSpecInferSchema } from
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { MockSpecSchema, type MockSpecInferSchema } from "./spec.js";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Load and validate a mock spec from disk.
|
|
6
|
+
*/
|
|
4
7
|
export async function loadSpecFromFile(specPath: string): Promise<MockSpecInferSchema> {
|
|
5
8
|
let raw: string
|
|
6
9
|
try {
|
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
|
+
}
|