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/README.md +476 -126
- package/dist/behavior.js +12 -0
- package/dist/history/historyRecorder.js +66 -0
- package/dist/history/types.js +2 -0
- package/dist/index.js +29 -18
- package/dist/logger/customLogger.js +75 -0
- package/dist/logger/formatters.js +82 -0
- package/dist/logger/types.js +2 -0
- package/dist/registerEndpoints.js +20 -3
- package/dist/requestMatch.js +40 -1
- package/dist/server.js +62 -2
- package/dist/spec.js +38 -2
- package/package.json +6 -1
- package/src/behavior.ts +15 -1
- package/src/history/historyRecorder.ts +77 -0
- package/src/history/types.ts +25 -0
- package/src/index.ts +34 -21
- package/src/logger/customLogger.ts +85 -0
- package/src/logger/formatters.ts +74 -0
- package/src/logger/types.ts +30 -0
- package/src/registerEndpoints.ts +24 -6
- package/src/requestMatch.ts +43 -1
- package/src/server.ts +77 -4
- package/src/spec.ts +40 -2
- package/tests/cors.test.ts +128 -0
- package/tests/headers.test.ts +124 -0
- package/tests/helpers.ts +2 -2
- package/tests/history.test.ts +188 -0
- package/tests/matching.test.ts +109 -0
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
|
+
});
|