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/server.ts CHANGED
@@ -1,10 +1,16 @@
1
- import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
1
+ import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest, type FastifyBaseLogger } from "fastify";
2
2
  import type { MockSpecInferSchema } from "./spec.js";
3
3
  import { registerEndpoints } from "./registerEndpoints.js";
4
4
  import swaggerUiDist from "swagger-ui-dist";
5
5
  import { generateOpenApi } from "./openapi.js";
6
6
  import fastifyStatic from "@fastify/static";
7
+ import fastifyCookie from "@fastify/cookie";
8
+ import fastifyCors from "@fastify/cors";
7
9
  import YAML from "yaml";
10
+ import { createLogger } from "./logger/customLogger.js";
11
+ import type { LoggerOptions } from "./logger/types.js";
12
+ import { HistoryRecorder } from "./history/historyRecorder.js";
13
+ import type { HistoryFilter } from "./history/types.js";
8
14
 
9
15
  type SwaggerUiDistModule = {
10
16
  getAbsoluteFSPath?: () => string;
@@ -43,11 +49,61 @@ function resolveServerUrl(req: FastifyRequest, baseUrl?: string): string {
43
49
  */
44
50
  export function buildServer(
45
51
  spec: MockSpecInferSchema,
46
- meta?: { specPath?: string; loadedAt?: string; baseUrl?: string }
52
+ meta?: { specPath?: string; loadedAt?: string; baseUrl?: string; logger?: FastifyBaseLogger | boolean }
47
53
  ): FastifyInstance {
48
54
  const app = Fastify({
49
- logger: true,
50
- trustProxy: true
55
+ logger: meta?.logger ?? true,
56
+ trustProxy: true,
57
+ disableRequestLogging: false
58
+ });
59
+
60
+ // Register CORS if configured
61
+ if (spec.settings.cors) {
62
+ app.register(fastifyCors, {
63
+ origin: spec.settings.cors.origin ?? true,
64
+ credentials: spec.settings.cors.credentials ?? false,
65
+ methods: spec.settings.cors.methods,
66
+ allowedHeaders: spec.settings.cors.allowedHeaders,
67
+ exposedHeaders: spec.settings.cors.exposedHeaders,
68
+ maxAge: spec.settings.cors.maxAge
69
+ });
70
+ }
71
+
72
+ // Register cookie parser plugin
73
+ app.register(fastifyCookie);
74
+
75
+ // Create history recorder
76
+ const history = new HistoryRecorder(1000);
77
+
78
+ // Record all requests in onRequest hook (after body parsing)
79
+ app.addHook("preHandler", async (req, reply) => {
80
+ const startTime = Date.now();
81
+
82
+ // Store start time for response hook
83
+ (req as FastifyRequest & { startTime?: number }).startTime = startTime;
84
+
85
+ history.record({
86
+ method: req.method,
87
+ url: req.url,
88
+ path: req.routeOptions?.url ?? req.url.split("?")[0],
89
+ query: req.query as Record<string, unknown>,
90
+ headers: req.headers,
91
+ body: req.body
92
+ });
93
+ });
94
+
95
+ // Update history with response details in onResponse hook
96
+ app.addHook("onResponse", async (req, reply) => {
97
+ const startTime = (req as FastifyRequest & { startTime?: number }).startTime ?? Date.now();
98
+ const responseTime = Date.now() - startTime;
99
+
100
+ // Find and update the last entry (just added in onRequest)
101
+ const entries = history.query({ limit: 1 });
102
+ if (entries.length > 0) {
103
+ const lastEntry = entries[entries.length - 1];
104
+ lastEntry.statusCode = reply.statusCode;
105
+ lastEntry.responseTime = responseTime;
106
+ }
51
107
  });
52
108
 
53
109
  /**
@@ -96,6 +152,23 @@ export function buildServer(
96
152
 
97
153
  app.get("/docs", docsRouteHandler);
98
154
 
155
+ // History endpoints
156
+ app.get("/__history", async (req) => {
157
+ const query = req.query as Record<string, string>;
158
+ const filter: HistoryFilter = {
159
+ endpoint: query.endpoint,
160
+ method: query.method,
161
+ statusCode: query.statusCode ? Number(query.statusCode) : undefined,
162
+ limit: query.limit ? Number(query.limit) : undefined
163
+ };
164
+ return { entries: history.query(filter), total: history.count() };
165
+ });
166
+
167
+ app.delete("/__history", async () => {
168
+ history.clear();
169
+ return { ok: true, message: "History cleared" };
170
+ });
171
+
99
172
  registerEndpoints(app, spec);
100
173
 
101
174
  return app;
package/src/spec.ts CHANGED
@@ -66,18 +66,32 @@ const TemplateValueSchema: z.ZodType<TemplateValue> = z.lazy(() =>
66
66
  export const MatchSchema = z.object({
67
67
  query: z.record(z.string(), PrimitiveSchema).optional(),
68
68
  // Exact match for top-level body fields only (keeps v1 simple)
69
- body: z.record(z.string(), PrimitiveSchema).optional()
69
+ body: z.record(z.string(), PrimitiveSchema).optional(),
70
+ // Header matching (case-insensitive keys)
71
+ headers: z.record(z.string(), z.string()).optional(),
72
+ // Cookie matching
73
+ cookies: z.record(z.string(), z.string()).optional()
70
74
  });
71
75
 
76
+ const DelaySchema = z.union([
77
+ z.number().int().min(0),
78
+ z.object({
79
+ min: z.number().int().min(0),
80
+ max: z.number().int().min(0)
81
+ })
82
+ ]);
83
+
72
84
  export const VariantSchema = z.object({
73
85
  name: z.string().min(1).optional(),
74
86
  match: MatchSchema.optional(),
75
87
 
76
88
  status: z.number().int().min(100).max(599).optional(),
77
89
  response: TemplateValueSchema,
90
+ headers: z.record(z.string(), z.string()).optional(),
78
91
 
79
92
  // Simulation overrides per variant (optional)
80
93
  delayMs: z.number().int().min(0).optional(),
94
+ delay: DelaySchema.optional(),
81
95
  errorRate: z.number().min(0).max(1).optional(),
82
96
  errorStatus: z.number().int().min(100).max(599).optional(),
83
97
  errorResponse: TemplateValueSchema.optional()
@@ -93,9 +107,23 @@ export const EndpointSchema = z.object({
93
107
  // Response behavior:
94
108
  status: z.number().int().min(200).max(599).default(200),
95
109
  response: TemplateValueSchema,
110
+ headers: z.record(z.string(), z.string()).optional(),
111
+
112
+ // Per-endpoint CORS override
113
+ cors: z
114
+ .object({
115
+ origin: z.union([z.string(), z.array(z.string()), z.boolean()]).optional(),
116
+ credentials: z.boolean().optional(),
117
+ methods: z.array(z.string()).optional(),
118
+ allowedHeaders: z.array(z.string()).optional(),
119
+ exposedHeaders: z.array(z.string()).optional(),
120
+ maxAge: z.number().int().optional()
121
+ })
122
+ .optional(),
96
123
 
97
124
  // Simulation (optional overrides)
98
125
  delayMs: z.number().int().min(0).optional(),
126
+ delay: DelaySchema.optional(),
99
127
  errorRate: z.number().min(0).max(1).optional(),
100
128
  errorStatus: z.number().int().min(100).max(599).optional(),
101
129
  errorResponse: TemplateValueSchema.optional()
@@ -109,7 +137,17 @@ export const MockSpecSchema = z.object({
109
137
  errorRate: z.number().min(0).max(1).default(0),
110
138
  errorStatus: z.number().int().min(100).max(599).default(500),
111
139
  errorResponse: TemplateValueSchema.default({ error: "Mock error" }),
112
- fakerSeed: z.number().int().min(0).optional()
140
+ fakerSeed: z.number().int().min(0).optional(),
141
+ cors: z
142
+ .object({
143
+ origin: z.union([z.string(), z.array(z.string()), z.boolean()]).optional(),
144
+ credentials: z.boolean().optional(),
145
+ methods: z.array(z.string()).optional(),
146
+ allowedHeaders: z.array(z.string()).optional(),
147
+ exposedHeaders: z.array(z.string()).optional(),
148
+ maxAge: z.number().int().optional()
149
+ })
150
+ .optional()
113
151
  })
114
152
  .default({ delayMs: 0, errorRate: 0, errorStatus: 500, errorResponse: { error: "Mock error" } }),
115
153
  endpoints: z.array(EndpointSchema).min(1)
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildTestServer } from "./helpers.js";
3
+
4
+ const baseSettings = {
5
+ delayMs: 0,
6
+ errorRate: 0,
7
+ errorStatus: 500,
8
+ errorResponse: { error: "Mock error" }
9
+ };
10
+
11
+ describe("CORS configuration", () => {
12
+ it("enables CORS with wildcard origin when configured", async () => {
13
+ const app = buildTestServer({
14
+ version: 1,
15
+ settings: {
16
+ ...baseSettings,
17
+ cors: {
18
+ origin: "*",
19
+ credentials: false
20
+ }
21
+ },
22
+ endpoints: [
23
+ {
24
+ method: "GET",
25
+ path: "/api/data",
26
+ response: { data: "value" }
27
+ }
28
+ ]
29
+ });
30
+
31
+ const res = await app.inject({
32
+ method: "GET",
33
+ url: "/api/data",
34
+ headers: { origin: "https://example.com" }
35
+ });
36
+
37
+ expect(res.statusCode).toBe(200);
38
+ expect(res.headers["access-control-allow-origin"]).toBe("*");
39
+
40
+ await app.close();
41
+ });
42
+
43
+ it("handles preflight OPTIONS requests", async () => {
44
+ const app = buildTestServer({
45
+ version: 1,
46
+ settings: {
47
+ ...baseSettings,
48
+ cors: {
49
+ origin: "https://example.com",
50
+ methods: ["GET", "POST"],
51
+ credentials: true
52
+ }
53
+ },
54
+ endpoints: [
55
+ {
56
+ method: "POST",
57
+ path: "/api/data",
58
+ response: { ok: true }
59
+ }
60
+ ]
61
+ });
62
+
63
+ const res = await app.inject({
64
+ method: "OPTIONS",
65
+ url: "/api/data",
66
+ headers: {
67
+ origin: "https://example.com",
68
+ "access-control-request-method": "POST"
69
+ }
70
+ });
71
+
72
+ expect(res.statusCode).toBe(204);
73
+ expect(res.headers["access-control-allow-origin"]).toBeTruthy();
74
+
75
+ await app.close();
76
+ });
77
+
78
+ it("allows specific origins when configured", async () => {
79
+ const app = buildTestServer({
80
+ version: 1,
81
+ settings: {
82
+ ...baseSettings,
83
+ cors: {
84
+ origin: "https://trusted.com",
85
+ credentials: true
86
+ }
87
+ },
88
+ endpoints: [
89
+ {
90
+ method: "GET",
91
+ path: "/api/secure",
92
+ response: { data: "secure" }
93
+ }
94
+ ]
95
+ });
96
+
97
+ const res = await app.inject({
98
+ method: "GET",
99
+ url: "/api/secure",
100
+ headers: { origin: "https://trusted.com" }
101
+ });
102
+
103
+ expect(res.statusCode).toBe(200);
104
+ expect(res.headers["access-control-allow-origin"]).toBe("https://trusted.com");
105
+ expect(res.headers["access-control-allow-credentials"]).toBe("true");
106
+
107
+ await app.close();
108
+ });
109
+
110
+ it("works without CORS configuration", async () => {
111
+ const app = buildTestServer({
112
+ version: 1,
113
+ settings: baseSettings,
114
+ endpoints: [
115
+ {
116
+ method: "GET",
117
+ path: "/api/data",
118
+ response: { data: "value" }
119
+ }
120
+ ]
121
+ });
122
+
123
+ const res = await app.inject({ method: "GET", url: "/api/data" });
124
+ expect(res.statusCode).toBe(200);
125
+
126
+ await app.close();
127
+ });
128
+ });
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildTestServer } from "./helpers.js";
3
+
4
+ const baseSettings = {
5
+ delayMs: 0,
6
+ errorRate: 0,
7
+ errorStatus: 500,
8
+ errorResponse: { error: "Mock error" }
9
+ };
10
+
11
+ describe("custom response headers", () => {
12
+ it("sets custom headers on endpoint responses", async () => {
13
+ const app = buildTestServer({
14
+ version: 1,
15
+ settings: baseSettings,
16
+ endpoints: [
17
+ {
18
+ method: "GET",
19
+ path: "/api/data",
20
+ response: { data: "value" },
21
+ headers: {
22
+ "X-Custom-Header": "test-value",
23
+ "Cache-Control": "no-cache"
24
+ }
25
+ }
26
+ ]
27
+ });
28
+
29
+ const res = await app.inject({ method: "GET", url: "/api/data" });
30
+ expect(res.statusCode).toBe(200);
31
+ expect(res.headers["x-custom-header"]).toBe("test-value");
32
+ expect(res.headers["cache-control"]).toBe("no-cache");
33
+
34
+ await app.close();
35
+ });
36
+
37
+ it("supports template strings in header values", async () => {
38
+ const app = buildTestServer({
39
+ version: 1,
40
+ settings: baseSettings,
41
+ endpoints: [
42
+ {
43
+ method: "GET",
44
+ path: "/users/:id",
45
+ response: { id: "{{params.id}}" },
46
+ headers: {
47
+ "X-User-ID": "{{params.id}}",
48
+ "X-Query-Type": "{{query.type}}"
49
+ }
50
+ }
51
+ ]
52
+ });
53
+
54
+ const res = await app.inject({ method: "GET", url: "/users/42?type=premium" });
55
+ expect(res.statusCode).toBe(200);
56
+ expect(res.headers["x-user-id"]).toBe("42");
57
+ expect(res.headers["x-query-type"]).toBe("premium");
58
+
59
+ await app.close();
60
+ });
61
+
62
+ it("variant headers override endpoint headers", async () => {
63
+ const app = buildTestServer({
64
+ version: 1,
65
+ settings: baseSettings,
66
+ endpoints: [
67
+ {
68
+ method: "GET",
69
+ path: "/api/data",
70
+ response: { source: "base" },
71
+ headers: {
72
+ "X-Source": "endpoint"
73
+ },
74
+ variants: [
75
+ {
76
+ name: "special",
77
+ match: { query: { mode: "special" } },
78
+ response: { source: "variant" },
79
+ headers: {
80
+ "X-Source": "variant"
81
+ }
82
+ }
83
+ ]
84
+ }
85
+ ]
86
+ });
87
+
88
+ const baseRes = await app.inject({ method: "GET", url: "/api/data" });
89
+ expect(baseRes.headers["x-source"]).toBe("endpoint");
90
+
91
+ const variantRes = await app.inject({ method: "GET", url: "/api/data?mode=special" });
92
+ expect(variantRes.headers["x-source"]).toBe("variant");
93
+
94
+ await app.close();
95
+ });
96
+
97
+ it("handles multiple custom headers", async () => {
98
+ const app = buildTestServer({
99
+ version: 1,
100
+ settings: baseSettings,
101
+ endpoints: [
102
+ {
103
+ method: "GET",
104
+ path: "/api/cors-test",
105
+ response: { ok: true },
106
+ headers: {
107
+ "Access-Control-Allow-Origin": "*",
108
+ "Access-Control-Allow-Methods": "GET, POST",
109
+ "X-RateLimit-Limit": "100",
110
+ "X-RateLimit-Remaining": "95"
111
+ }
112
+ }
113
+ ]
114
+ });
115
+
116
+ const res = await app.inject({ method: "GET", url: "/api/cors-test" });
117
+ expect(res.headers["access-control-allow-origin"]).toBe("*");
118
+ expect(res.headers["access-control-allow-methods"]).toBe("GET, POST");
119
+ expect(res.headers["x-ratelimit-limit"]).toBe("100");
120
+ expect(res.headers["x-ratelimit-remaining"]).toBe("95");
121
+
122
+ await app.close();
123
+ });
124
+ });
package/tests/helpers.ts CHANGED
@@ -17,12 +17,12 @@ export async function loadFixture(path: string): Promise<MockSpecInferSchema> {
17
17
  */
18
18
  export async function buildTestServerFromFixture(path: string): Promise<FastifyInstance> {
19
19
  const spec = await loadFixture(path);
20
- return buildServer(spec, { specPath: path, loadedAt: "now" });
20
+ return buildServer(spec, { specPath: path, loadedAt: "now", logger: false });
21
21
  }
22
22
 
23
23
  /**
24
24
  * Build a Fastify instance from an in-memory spec object.
25
25
  */
26
26
  export function buildTestServer(spec: MockSpecInferSchema): FastifyInstance {
27
- return buildServer(spec, { specPath: "inline", loadedAt: "now" });
27
+ return buildServer(spec, { specPath: "inline", loadedAt: "now", logger: false });
28
28
  }
@@ -0,0 +1,188 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildTestServer } from "./helpers.js";
3
+
4
+ const baseSettings = {
5
+ delayMs: 0,
6
+ errorRate: 0,
7
+ errorStatus: 500,
8
+ errorResponse: { error: "Mock error" }
9
+ };
10
+
11
+ describe("request history", () => {
12
+ it("records all requests in history", async () => {
13
+ const app = buildTestServer({
14
+ version: 1,
15
+ settings: baseSettings,
16
+ endpoints: [
17
+ {
18
+ method: "GET",
19
+ path: "/api/test",
20
+ response: { ok: true }
21
+ }
22
+ ]
23
+ });
24
+
25
+ await app.inject({ method: "GET", url: "/api/test?foo=bar" });
26
+ await app.inject({ method: "GET", url: "/api/test?foo=baz" });
27
+
28
+ const historyRes = await app.inject({ method: "GET", url: "/__history" });
29
+ expect(historyRes.statusCode).toBe(200);
30
+
31
+ const history = historyRes.json();
32
+ expect(history.entries).toBeInstanceOf(Array);
33
+ expect(history.entries.length).toBeGreaterThanOrEqual(2);
34
+ expect(history.total).toBeGreaterThanOrEqual(2);
35
+
36
+ await app.close();
37
+ });
38
+
39
+ it("records request details including method, url, headers, and body", async () => {
40
+ const app = buildTestServer({
41
+ version: 1,
42
+ settings: baseSettings,
43
+ endpoints: [
44
+ {
45
+ method: "POST",
46
+ path: "/api/create",
47
+ response: { id: 1 }
48
+ }
49
+ ]
50
+ });
51
+
52
+ await app.inject({
53
+ method: "POST",
54
+ url: "/api/create",
55
+ headers: { "content-type": "application/json" },
56
+ payload: { name: "test" }
57
+ });
58
+
59
+ const historyRes = await app.inject({ method: "GET", url: "/__history?endpoint=/api/create" });
60
+ const history = historyRes.json();
61
+
62
+ // Should have at least one POST to /api/create
63
+ const postEntries = history.entries.filter((e: {method: string; path: string}) =>
64
+ e.method === "POST" && e.path === "/api/create"
65
+ );
66
+ expect(postEntries.length).toBeGreaterThan(0);
67
+
68
+ const lastEntry = postEntries[postEntries.length - 1];
69
+ expect(lastEntry.method).toBe("POST");
70
+ expect(lastEntry.url).toContain("/api/create");
71
+ expect(lastEntry.headers["content-type"]).toBe("application/json");
72
+ expect(lastEntry.body).toEqual({ name: "test" });
73
+
74
+ await app.close();
75
+ });
76
+
77
+ it("filters history by endpoint", async () => {
78
+ const app = buildTestServer({
79
+ version: 1,
80
+ settings: baseSettings,
81
+ endpoints: [
82
+ {
83
+ method: "GET",
84
+ path: "/api/users",
85
+ response: { users: [] }
86
+ },
87
+ {
88
+ method: "GET",
89
+ path: "/api/posts",
90
+ response: { posts: [] }
91
+ }
92
+ ]
93
+ });
94
+
95
+ await app.inject({ method: "GET", url: "/api/users" });
96
+ await app.inject({ method: "GET", url: "/api/posts" });
97
+ await app.inject({ method: "GET", url: "/api/users" });
98
+
99
+ const historyRes = await app.inject({ method: "GET", url: "/__history?endpoint=/api/users" });
100
+ const history = historyRes.json();
101
+
102
+ expect(history.entries.every((e: { path: string }) => e.path === "/api/users")).toBe(true);
103
+
104
+ await app.close();
105
+ });
106
+
107
+ it("filters history by method", async () => {
108
+ const app = buildTestServer({
109
+ version: 1,
110
+ settings: baseSettings,
111
+ endpoints: [
112
+ {
113
+ method: "GET",
114
+ path: "/api/data",
115
+ response: { data: "get" }
116
+ },
117
+ {
118
+ method: "POST",
119
+ path: "/api/data",
120
+ response: { data: "post" }
121
+ }
122
+ ]
123
+ });
124
+
125
+ await app.inject({ method: "GET", url: "/api/data" });
126
+ await app.inject({ method: "POST", url: "/api/data" });
127
+ await app.inject({ method: "GET", url: "/api/data" });
128
+
129
+ const historyRes = await app.inject({ method: "GET", url: "/__history?method=POST" });
130
+ const history = historyRes.json();
131
+
132
+ expect(history.entries.every((e: { method: string }) => e.method === "POST")).toBe(true);
133
+
134
+ await app.close();
135
+ });
136
+
137
+ it("clears history on DELETE request", async () => {
138
+ const app = buildTestServer({
139
+ version: 1,
140
+ settings: baseSettings,
141
+ endpoints: [
142
+ {
143
+ method: "GET",
144
+ path: "/api/test",
145
+ response: { ok: true }
146
+ }
147
+ ]
148
+ });
149
+
150
+ await app.inject({ method: "GET", url: "/api/test" });
151
+
152
+ const beforeClear = await app.inject({ method: "GET", url: "/__history" });
153
+ expect(beforeClear.json().entries.length).toBeGreaterThan(0);
154
+
155
+ await app.inject({ method: "DELETE", url: "/__history" });
156
+
157
+ const afterClear = await app.inject({ method: "GET", url: "/__history" });
158
+ // Should only have the history requests themselves
159
+ expect(afterClear.json().entries.length).toBeLessThanOrEqual(2);
160
+
161
+ await app.close();
162
+ });
163
+
164
+ it("limits history entries with limit parameter", async () => {
165
+ const app = buildTestServer({
166
+ version: 1,
167
+ settings: baseSettings,
168
+ endpoints: [
169
+ {
170
+ method: "GET",
171
+ path: "/api/test",
172
+ response: { ok: true }
173
+ }
174
+ ]
175
+ });
176
+
177
+ for (let i = 0; i < 10; i++) {
178
+ await app.inject({ method: "GET", url: "/api/test" });
179
+ }
180
+
181
+ const historyRes = await app.inject({ method: "GET", url: "/__history?limit=3" });
182
+ const history = historyRes.json();
183
+
184
+ expect(history.entries.length).toBeLessThanOrEqual(3);
185
+
186
+ await app.close();
187
+ });
188
+ });