api-json-server 1.1.0 → 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/src/behavior.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import type { TemplateValue } from "./spec.js";
2
+ import { faker } from "@faker-js/faker";
3
+
4
+ export type DelayConfig = number | { min: number; max: number };
2
5
 
3
6
  export type BehaviorSettings = {
4
7
  delayMs: number;
@@ -7,7 +10,18 @@ export type BehaviorSettings = {
7
10
  errorResponse: TemplateValue;
8
11
  };
9
12
 
10
- export type BehaviorOverrides = Partial<BehaviorSettings>;
13
+ export type BehaviorOverrides = Partial<BehaviorSettings> & {
14
+ delay?: DelayConfig;
15
+ };
16
+
17
+ /**
18
+ * Resolve a delay value from either a number or a range configuration.
19
+ */
20
+ export function resolveDelay(delay?: DelayConfig): number {
21
+ if (!delay) return 0;
22
+ if (typeof delay === "number") return delay;
23
+ return faker.number.int({ min: delay.min, max: delay.max });
24
+ }
11
25
 
12
26
  /**
13
27
  * Pause for the given number of milliseconds.
@@ -0,0 +1,77 @@
1
+ import type { HistoryEntry, HistoryFilter } from "./types.js";
2
+ import { randomUUID } from "node:crypto";
3
+
4
+ /**
5
+ * In-memory request history recorder.
6
+ */
7
+ export class HistoryRecorder {
8
+ private entries: HistoryEntry[] = [];
9
+ private readonly maxEntries: number;
10
+
11
+ /**
12
+ * Create a new history recorder.
13
+ * @param maxEntries Maximum number of entries to keep (default 1000).
14
+ */
15
+ constructor(maxEntries = 1000) {
16
+ this.maxEntries = maxEntries;
17
+ }
18
+
19
+ /**
20
+ * Record a new request.
21
+ */
22
+ record(entry: Omit<HistoryEntry, "id" | "timestamp">): HistoryEntry {
23
+ const fullEntry: HistoryEntry = {
24
+ id: randomUUID(),
25
+ timestamp: new Date().toISOString(),
26
+ ...entry
27
+ };
28
+
29
+ this.entries.push(fullEntry);
30
+
31
+ // Keep only the most recent entries
32
+ if (this.entries.length > this.maxEntries) {
33
+ this.entries.shift();
34
+ }
35
+
36
+ return fullEntry;
37
+ }
38
+
39
+ /**
40
+ * Get all history entries, optionally filtered.
41
+ */
42
+ query(filter?: HistoryFilter): HistoryEntry[] {
43
+ let results = this.entries;
44
+
45
+ if (filter?.endpoint) {
46
+ results = results.filter((e) => e.path === filter.endpoint);
47
+ }
48
+
49
+ if (filter?.method) {
50
+ results = results.filter((e) => e.method.toUpperCase() === filter.method?.toUpperCase());
51
+ }
52
+
53
+ if (filter?.statusCode !== undefined) {
54
+ results = results.filter((e) => e.statusCode === filter.statusCode);
55
+ }
56
+
57
+ if (filter?.limit && filter.limit > 0) {
58
+ results = results.slice(-filter.limit);
59
+ }
60
+
61
+ return results;
62
+ }
63
+
64
+ /**
65
+ * Clear all history entries.
66
+ */
67
+ clear(): void {
68
+ this.entries = [];
69
+ }
70
+
71
+ /**
72
+ * Get the total number of recorded entries.
73
+ */
74
+ count(): number {
75
+ return this.entries.length;
76
+ }
77
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Request history entry structure.
3
+ */
4
+ export interface HistoryEntry {
5
+ id: string;
6
+ timestamp: string;
7
+ method: string;
8
+ url: string;
9
+ path: string;
10
+ query: Record<string, unknown>;
11
+ headers: Record<string, string | string[] | undefined>;
12
+ body: unknown;
13
+ statusCode?: number;
14
+ responseTime?: number;
15
+ }
16
+
17
+ /**
18
+ * Filter options for querying history.
19
+ */
20
+ export interface HistoryFilter {
21
+ endpoint?: string;
22
+ method?: string;
23
+ statusCode?: number;
24
+ limit?: number;
25
+ }
package/src/index.ts CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { Command } from "commander";
4
4
  import { watch } from "node:fs";
5
+ import { createLogger, logServerStart, logServerReload } from "./logger/customLogger.js";
6
+ import type { LoggerOptions } from "./logger/types.js";
5
7
 
6
8
  const program = new Command();
7
9
 
@@ -18,14 +20,16 @@ program
18
20
  .option("--watch", "Reload when spec file changes", true)
19
21
  .option("--no-watch", "Disable reload when spec file changes")
20
22
  .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 }) => {
23
+ .option("--log-format <format>", "Log format: pretty or json", "pretty")
24
+ .option("--log-level <level>", "Log level: trace, debug, info, warn, error, fatal", "info")
25
+ .action(async (opts: { port: string; spec: string; watch: boolean; baseUrl?: string; logFormat: string; logLevel: string }) => {
22
26
  await startCommand(opts);
23
27
  });
24
28
 
25
29
  /**
26
30
  * Run the mock server CLI command.
27
31
  */
28
- async function startCommand(opts: { port: string; spec: string; watch: boolean; baseUrl?: string }): Promise<void> {
32
+ async function startCommand(opts: { port: string; spec: string; watch: boolean; baseUrl?: string; logFormat: string; logLevel: string }): Promise<void> {
29
33
  const port = Number(opts.port);
30
34
 
31
35
  if (!Number.isFinite(port) || port <= 0) {
@@ -35,6 +39,18 @@ async function startCommand(opts: { port: string; spec: string; watch: boolean;
35
39
 
36
40
  const specPath = opts.spec;
37
41
 
42
+ // Create logger based on CLI options
43
+ const logFormat = opts.logFormat === "json" ? "json" : "pretty";
44
+ const logLevel = ["trace", "debug", "info", "warn", "error", "fatal"].includes(opts.logLevel)
45
+ ? opts.logLevel as LoggerOptions["level"]
46
+ : "info";
47
+
48
+ const logger = createLogger({
49
+ enabled: true,
50
+ format: logFormat,
51
+ level: logLevel
52
+ });
53
+
38
54
  const { loadSpecFromFile } = await import("./loadSpec.js");
39
55
  const { buildServer } = await import("./server.js");
40
56
 
@@ -49,9 +65,9 @@ async function startCommand(opts: { port: string; spec: string; watch: boolean;
49
65
  const loadedAt = new Date().toISOString();
50
66
 
51
67
  const spec = await loadSpecFromFile(specPath);
52
- console.log(`Loaded spec v${spec.version} with ${spec.endpoints.length} endpoint(s).`);
68
+ logger.info(`Loaded spec v${spec.version} with ${spec.endpoints.length} endpoint(s).`);
53
69
 
54
- const nextApp = buildServer(spec, { specPath, loadedAt, baseUrl: opts.baseUrl });
70
+ const nextApp = buildServer(spec, { specPath, loadedAt, baseUrl: opts.baseUrl, logger: true });
55
71
  try {
56
72
  await nextApp.listen({ port, host: "0.0.0.0" });
57
73
  } catch (err) {
@@ -59,8 +75,7 @@ async function startCommand(opts: { port: string; spec: string; watch: boolean;
59
75
  throw err;
60
76
  }
61
77
 
62
- nextApp.log.info(`Mock server running on http://localhost:${port}`);
63
- nextApp.log.info(`Spec: ${specPath} (loadedAt=${loadedAt})`);
78
+ logServerStart(nextApp.log, port, specPath);
64
79
 
65
80
  return nextApp;
66
81
  }
@@ -73,36 +88,34 @@ async function startCommand(opts: { port: string; spec: string; watch: boolean;
73
88
  isReloading = true;
74
89
 
75
90
  try {
76
- console.log("Reloading spec...");
91
+ logger.info("Reloading spec...");
77
92
 
78
93
  // 1) Stop accepting requests on the old server FIRST
79
94
  if (app) {
80
- console.log("Closing current server...");
95
+ logger.debug("Closing current server...");
81
96
  await app.close();
82
- console.log("Current server closed.");
97
+ logger.debug("Current server closed.");
83
98
  app = null;
84
99
  }
85
100
 
86
101
  // 2) Start a new server on the same port with the updated spec
87
102
  app = await startWithSpec();
88
103
 
89
- console.log("Reload complete.");
104
+ logServerReload(logger, true);
90
105
  } catch (err) {
91
- console.error("Reload failed.");
92
-
93
- // At this point the old server may already be closed. We want visibility.
94
- console.error(String(err));
106
+ const errorMsg = err instanceof Error ? err.message : String(err);
107
+ logServerReload(logger, false, errorMsg);
95
108
 
96
109
  // Optional: try to start again to avoid being down
97
110
  try {
98
111
  if (!app) {
99
- console.log("Attempting to start server again after reload failure...");
112
+ logger.info("Attempting to start server again after reload failure...");
100
113
  app = await startWithSpec();
101
- console.log("Recovery start succeeded.");
114
+ logger.info("Recovery start succeeded.");
102
115
  }
103
116
  } catch (err2) {
104
- console.error("Recovery start failed. Server is down until next successful reload.");
105
- console.error(String(err2));
117
+ logger.error("Recovery start failed. Server is down until next successful reload.");
118
+ logger.error(err2);
106
119
  }
107
120
  } finally {
108
121
  isReloading = false;
@@ -113,7 +126,7 @@ async function startCommand(opts: { port: string; spec: string; watch: boolean;
113
126
  try {
114
127
  app = await startWithSpec();
115
128
  } catch (err) {
116
- console.error(String(err));
129
+ logger.error(err);
117
130
  process.exit(1);
118
131
  }
119
132
 
@@ -126,12 +139,12 @@ async function startCommand(opts: { port: string; spec: string; watch: boolean;
126
139
  }
127
140
 
128
141
  if (opts.watch) {
129
- console.log(`Watching spec file for changes: ${specPath}`);
142
+ logger.info(`Watching spec file for changes: ${specPath}`);
130
143
 
131
144
  // fs.watch emits multiple events; debounce to avoid rapid reload loops
132
145
  watch(specPath, onSpecChange);
133
146
  } else {
134
- console.log("Watch disabled (--no-watch).");
147
+ logger.info("Watch disabled (--no-watch).");
135
148
  }
136
149
  }
137
150
 
@@ -0,0 +1,85 @@
1
+ import type { FastifyBaseLogger } from "fastify";
2
+ import pino from "pino";
3
+ import type { LoggerOptions } from "./types.js";
4
+ import { formatStatusCode, formatMethod, formatResponseTime, formatTimestamp, formatLogLevel } from "./formatters.js";
5
+
6
+ /**
7
+ * Create a custom logger for the mock server.
8
+ */
9
+ export function createLogger(options: LoggerOptions): FastifyBaseLogger {
10
+ if (!options.enabled) {
11
+ return pino({ level: "silent" });
12
+ }
13
+
14
+ if (options.format === "json") {
15
+ return pino({
16
+ level: options.level,
17
+ timestamp: pino.stdTimeFunctions.isoTime
18
+ });
19
+ }
20
+
21
+ // Pretty format with custom output (simplified to avoid serialization issues in tests)
22
+ return pino({
23
+ level: options.level,
24
+ transport: {
25
+ target: "pino-pretty",
26
+ options: {
27
+ colorize: true,
28
+ translateTime: "HH:MM:ss",
29
+ ignore: "pid,hostname",
30
+ messageFormat: "{msg}"
31
+ }
32
+ }
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Format and log a request/response pair.
38
+ */
39
+ export function logRequest(
40
+ logger: FastifyBaseLogger,
41
+ method: string,
42
+ url: string,
43
+ statusCode: number,
44
+ responseTime: number
45
+ ): void {
46
+ const formattedMethod = formatMethod(method);
47
+ const formattedStatus = formatStatusCode(statusCode);
48
+ const formattedTime = formatResponseTime(responseTime);
49
+
50
+ logger.info(`${formattedMethod} ${url} ${formattedStatus} ${formattedTime}`);
51
+ }
52
+
53
+ /**
54
+ * Log server startup.
55
+ */
56
+ export function logServerStart(logger: FastifyBaseLogger, port: number, specPath: string): void {
57
+ logger.info(`🚀 Mock server running on http://localhost:${port}`);
58
+ logger.info(`📄 Spec: ${specPath}`);
59
+ logger.info(`📖 Docs: http://localhost:${port}/docs`);
60
+ }
61
+
62
+ /**
63
+ * Log server reload.
64
+ */
65
+ export function logServerReload(logger: FastifyBaseLogger, success: boolean, error?: string): void {
66
+ if (success) {
67
+ logger.info("✅ Spec reloaded successfully");
68
+ } else {
69
+ logger.error(`❌ Reload failed: ${error}`);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Log endpoint registration.
75
+ */
76
+ export function logEndpointRegistered(
77
+ logger: FastifyBaseLogger,
78
+ method: string,
79
+ path: string,
80
+ status?: number
81
+ ): void {
82
+ const formattedMethod = formatMethod(method);
83
+ const statusInfo = status ? ` → ${status}` : "";
84
+ logger.debug(`Registered ${formattedMethod} ${path}${statusInfo}`);
85
+ }
@@ -0,0 +1,74 @@
1
+ import chalk from "chalk";
2
+
3
+ /**
4
+ * Format HTTP status code with color based on status range.
5
+ */
6
+ export function formatStatusCode(statusCode: number): string {
7
+ if (statusCode >= 500) {
8
+ return chalk.red(statusCode.toString());
9
+ }
10
+ if (statusCode >= 400) {
11
+ return chalk.yellow(statusCode.toString());
12
+ }
13
+ if (statusCode >= 300) {
14
+ return chalk.cyan(statusCode.toString());
15
+ }
16
+ if (statusCode >= 200) {
17
+ return chalk.green(statusCode.toString());
18
+ }
19
+ return chalk.white(statusCode.toString());
20
+ }
21
+
22
+ /**
23
+ * Format HTTP method with color.
24
+ */
25
+ export function formatMethod(method: string): string {
26
+ const colors: Record<string, (str: string) => string> = {
27
+ GET: chalk.green,
28
+ POST: chalk.blue,
29
+ PUT: chalk.yellow,
30
+ PATCH: chalk.magenta,
31
+ DELETE: chalk.red,
32
+ HEAD: chalk.gray,
33
+ OPTIONS: chalk.cyan
34
+ };
35
+ const formatter = colors[method.toUpperCase()] || chalk.white;
36
+ return formatter(method.toUpperCase().padEnd(7));
37
+ }
38
+
39
+ /**
40
+ * Format response time with color based on duration.
41
+ */
42
+ export function formatResponseTime(ms: number): string {
43
+ const formatted = `${ms.toFixed(2)}ms`;
44
+ if (ms > 1000) return chalk.red(formatted);
45
+ if (ms > 500) return chalk.yellow(formatted);
46
+ if (ms > 100) return chalk.cyan(formatted);
47
+ return chalk.green(formatted);
48
+ }
49
+
50
+ /**
51
+ * Format timestamp in readable format.
52
+ */
53
+ export function formatTimestamp(date: Date): string {
54
+ const hours = date.getHours().toString().padStart(2, "0");
55
+ const minutes = date.getMinutes().toString().padStart(2, "0");
56
+ const seconds = date.getSeconds().toString().padStart(2, "0");
57
+ return chalk.gray(`[${hours}:${minutes}:${seconds}]`);
58
+ }
59
+
60
+ /**
61
+ * Format a log level with appropriate color.
62
+ */
63
+ export function formatLogLevel(level: string): string {
64
+ const colors: Record<string, (str: string) => string> = {
65
+ trace: chalk.gray,
66
+ debug: chalk.cyan,
67
+ info: chalk.blue,
68
+ warn: chalk.yellow,
69
+ error: chalk.red,
70
+ fatal: chalk.bgRed.white
71
+ };
72
+ const formatter = colors[level.toLowerCase()] || chalk.white;
73
+ return formatter(level.toUpperCase().padEnd(5));
74
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Logger configuration options.
3
+ */
4
+ export interface LoggerOptions {
5
+ /**
6
+ * Enable/disable logging.
7
+ */
8
+ enabled: boolean;
9
+
10
+ /**
11
+ * Log format: 'pretty' for colored console output, 'json' for structured logs.
12
+ */
13
+ format: "pretty" | "json";
14
+
15
+ /**
16
+ * Log level: 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
17
+ */
18
+ level: "trace" | "debug" | "info" | "warn" | "error" | "fatal";
19
+ }
20
+
21
+ /**
22
+ * Request log entry structure.
23
+ */
24
+ export interface RequestLogEntry {
25
+ method: string;
26
+ url: string;
27
+ statusCode: number;
28
+ responseTime: number;
29
+ timestamp: string;
30
+ }
@@ -2,11 +2,14 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
2
2
  import type { MockSpecInferSchema, EndpointSpecInferSchema, TemplateValue } from "./spec.js";
3
3
  import { matchRequest, toRecord } from "./requestMatch.js";
4
4
  import { createRenderContext, renderTemplateValue } from "./responseRenderer.js";
5
- import { resolveBehavior, shouldFail, sleep, type BehaviorOverrides } from "./behavior.js";
5
+ import { resolveBehavior, shouldFail, sleep, resolveDelay, type BehaviorOverrides, type DelayConfig } from "./behavior.js";
6
+ import { logEndpointRegistered } from "./logger/customLogger.js";
6
7
 
7
8
  type ResponseSource = {
8
9
  status?: number;
9
10
  response: TemplateValue;
11
+ headers?: Record<string, string>;
12
+ delay?: DelayConfig;
10
13
  } & BehaviorOverrides;
11
14
 
12
15
  /**
@@ -19,6 +22,8 @@ function selectVariant(req: FastifyRequest, endpoint: EndpointSpecInferSchema):
19
22
  return {
20
23
  status: variant.status,
21
24
  response: variant.response,
25
+ headers: variant.headers,
26
+ delay: variant.delay,
22
27
  delayMs: variant.delayMs,
23
28
  errorRate: variant.errorRate,
24
29
  errorStatus: variant.errorStatus,
@@ -36,6 +41,8 @@ function selectEndpointSource(endpoint: EndpointSpecInferSchema): ResponseSource
36
41
  return {
37
42
  status: endpoint.status,
38
43
  response: endpoint.response,
44
+ headers: endpoint.headers,
45
+ delay: endpoint.delay,
39
46
  delayMs: endpoint.delayMs,
40
47
  errorRate: endpoint.errorRate,
41
48
  errorStatus: endpoint.errorStatus,
@@ -54,9 +61,7 @@ export function registerEndpoints(app: FastifyInstance, spec: MockSpecInferSchem
54
61
  handler: buildEndpointHandler(spec, endpoint)
55
62
  });
56
63
 
57
- app.log.info(
58
- `Registered ${endpoint.method} ${endpoint.path} -> ${endpoint.status} (delay=${endpoint.delayMs ?? spec.settings.delayMs}ms, errorRate=${endpoint.errorRate ?? spec.settings.errorRate})`
59
- );
64
+ logEndpointRegistered(app.log, endpoint.method, endpoint.path, endpoint.status);
60
65
  }
61
66
  }
62
67
 
@@ -75,8 +80,10 @@ function buildEndpointHandler(spec: MockSpecInferSchema, endpoint: EndpointSpecI
75
80
  const source = variant ?? selectEndpointSource(endpoint);
76
81
  const behavior = resolveBehavior(spec.settings, endpoint, source);
77
82
 
78
- if (behavior.delayMs > 0) {
79
- await sleep(behavior.delayMs);
83
+ // Resolve delay (supports both delay and delayMs, with delay taking precedence)
84
+ const delayValue = source.delay ? resolveDelay(source.delay) : behavior.delayMs;
85
+ if (delayValue > 0) {
86
+ await sleep(delayValue);
80
87
  }
81
88
 
82
89
  const params = toRecord(req.params);
@@ -89,6 +96,17 @@ function buildEndpointHandler(spec: MockSpecInferSchema, endpoint: EndpointSpecI
89
96
  return renderTemplateValue(behavior.errorResponse, renderContext);
90
97
  }
91
98
 
99
+ // Apply custom headers (with template support)
100
+ const headers = source.headers ?? endpoint.headers;
101
+ if (headers) {
102
+ for (const [key, value] of Object.entries(headers)) {
103
+ const renderedValue = typeof value === "string"
104
+ ? String(renderTemplateValue(value, renderContext))
105
+ : String(value);
106
+ reply.header(key, renderedValue);
107
+ }
108
+ }
109
+
92
110
  const rendered = renderTemplateValue(source.response, renderContext);
93
111
 
94
112
  reply.code(source.status ?? endpoint.status ?? 200);
@@ -4,6 +4,8 @@ import type { Primitive } from "./spec.js";
4
4
  export type MatchRule = {
5
5
  query?: Record<string, Primitive>;
6
6
  body?: Record<string, Primitive>;
7
+ headers?: Record<string, string>;
8
+ cookies?: Record<string, string>;
7
9
  };
8
10
 
9
11
  /**
@@ -52,11 +54,51 @@ function bodyMatches(req: FastifyRequest, expected?: Record<string, Primitive>):
52
54
  }
53
55
 
54
56
  /**
55
- * Check if a request matches query/body rules.
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.
56
96
  */
57
97
  export function matchRequest(req: FastifyRequest, match?: MatchRule): boolean {
58
98
  if (!match) return true;
59
99
  if (!queryMatches(req, match.query)) return false;
60
100
  if (!bodyMatches(req, match.body)) return false;
101
+ if (!headersMatch(req, match.headers)) return false;
102
+ if (!cookiesMatch(req, match.cookies)) return false;
61
103
  return true;
62
104
  }