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.
@@ -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
@@ -1,4 +1,5 @@
1
1
  {
2
+ "$schema": "./mockserve.spec.schema.json",
2
3
  "version": 1,
3
4
  "settings": {
4
5
  "delayMs": 0,
@@ -0,0 +1,7 @@
1
+ {
2
+ "$ref": "#/definitions/MockServeSpec",
3
+ "definitions": {
4
+ "MockServeSpec": {}
5
+ },
6
+ "$schema": "http://json-schema.org/draft-07/schema#"
7
+ }
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "api-json-server",
3
- "version": "1.0.1",
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
+ });
@@ -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
- .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
- }
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
- const specPath = opts.spec;
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
- const { loadSpecFromFile } = await import("./loadSpec.js");
31
- const { buildServer } = await import("./server.js");
31
+ if (!Number.isFinite(port) || port <= 0) {
32
+ console.error(`Invalid port: ${opts.port}`);
33
+ process.exit(1);
34
+ }
32
35
 
33
- let app = null as null | import("fastify").FastifyInstance;
34
- let isReloading = false;
35
- let debounceTimer: NodeJS.Timeout | null = null;
36
+ const specPath = opts.spec;
36
37
 
37
- async function startWithSpec() {
38
- const loadedAt = new Date().toISOString();
38
+ const { loadSpecFromFile } = await import("./loadSpec.js");
39
+ const { buildServer } = await import("./server.js");
39
40
 
40
- const spec = await loadSpecFromFile(specPath);
41
- console.log(`Loaded spec v${spec.version} with ${spec.endpoints.length} endpoint(s).`);
41
+ let app = null as null | import("fastify").FastifyInstance;
42
+ let isReloading = false;
43
+ let debounceTimer: NodeJS.Timeout | null = null;
42
44
 
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
- }
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
- nextApp.log.info(`Mock server running on http://localhost:${port}`);
52
- nextApp.log.info(`Spec: ${specPath} (loadedAt=${loadedAt})`);
51
+ const spec = await loadSpecFromFile(specPath);
52
+ console.log(`Loaded spec v${spec.version} with ${spec.endpoints.length} endpoint(s).`);
53
53
 
54
- return nextApp;
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
- async function reload() {
58
- if (isReloading) return;
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
- 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
- }
65
+ return nextApp;
66
+ }
71
67
 
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
- }
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
- // 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).");
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 'node:fs/promises'
2
- import { MockSpecSchema, type MockSpecInferSchema } from './spec.js'
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
+ }