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 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);
@@ -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>;
@@ -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
+ }