api-json-server 1.0.1 → 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 +636 -0
- package/dist/behavior.js +44 -0
- package/dist/history/historyRecorder.js +66 -0
- package/dist/history/types.js +2 -0
- package/dist/index.js +126 -5
- package/dist/loadSpec.js +32 -0
- package/dist/logger/customLogger.js +75 -0
- package/dist/logger/formatters.js +82 -0
- package/dist/logger/types.js +2 -0
- package/dist/openapi.js +152 -0
- package/dist/registerEndpoints.js +97 -0
- package/dist/requestMatch.js +99 -0
- package/dist/responseRenderer.js +98 -0
- package/dist/server.js +210 -0
- package/dist/spec.js +146 -0
- package/dist/stringTemplate.js +55 -0
- package/examples/auth-variants.json +31 -0
- package/examples/basic-crud.json +46 -0
- package/examples/companies-nested.json +47 -0
- package/examples/orders-and-matches.json +49 -0
- package/examples/users-faker.json +35 -0
- package/mock.spec.json +1 -0
- package/mockserve.spec.schema.json +7 -0
- package/package.json +20 -3
- package/scripts/build-schema.ts +21 -0
- package/src/behavior.ts +56 -0
- package/src/history/historyRecorder.ts +77 -0
- package/src/history/types.ts +25 -0
- package/src/index.ts +124 -85
- package/src/loadSpec.ts +5 -2
- package/src/logger/customLogger.ts +85 -0
- package/src/logger/formatters.ts +74 -0
- package/src/logger/types.ts +30 -0
- package/src/openapi.ts +203 -0
- package/src/registerEndpoints.ts +94 -162
- package/src/requestMatch.ts +104 -0
- package/src/responseRenderer.ts +112 -0
- package/src/server.ts +236 -14
- package/src/spec.ts +108 -8
- package/src/stringTemplate.ts +55 -0
- package/tests/behavior.test.ts +88 -0
- package/tests/cors.test.ts +128 -0
- package/tests/faker.test.ts +175 -0
- package/tests/fixtures/spec.basic.json +39 -0
- package/tests/headers.test.ts +124 -0
- package/tests/helpers.ts +28 -0
- package/tests/history.test.ts +188 -0
- package/tests/matching.test.ts +245 -0
- package/tests/server.test.ts +73 -0
- package/tests/template.test.ts +90 -0
- package/src/template.ts +0 -61
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
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("matching rules", () => {
|
|
12
|
+
it("returns 404 when endpoint-level query match fails", async () => {
|
|
13
|
+
const app = buildTestServer({
|
|
14
|
+
version: 1,
|
|
15
|
+
settings: baseSettings,
|
|
16
|
+
endpoints: [
|
|
17
|
+
{
|
|
18
|
+
method: "GET",
|
|
19
|
+
path: "/search",
|
|
20
|
+
match: { query: { type: "premium" } },
|
|
21
|
+
response: { ok: true }
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const res = await app.inject({ method: "GET", url: "/search?type=basic" });
|
|
27
|
+
expect(res.statusCode).toBe(404);
|
|
28
|
+
|
|
29
|
+
await app.close();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("matches endpoint-level body rules for POST", async () => {
|
|
33
|
+
const app = buildTestServer({
|
|
34
|
+
version: 1,
|
|
35
|
+
settings: baseSettings,
|
|
36
|
+
endpoints: [
|
|
37
|
+
{
|
|
38
|
+
method: "POST",
|
|
39
|
+
path: "/login",
|
|
40
|
+
match: { body: { password: "secret" } },
|
|
41
|
+
response: { ok: true }
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const ok = await app.inject({ method: "POST", url: "/login", payload: { password: "secret" } });
|
|
47
|
+
expect(ok.statusCode).toBe(200);
|
|
48
|
+
|
|
49
|
+
const bad = await app.inject({ method: "POST", url: "/login", payload: { password: "wrong" } });
|
|
50
|
+
expect(bad.statusCode).toBe(404);
|
|
51
|
+
|
|
52
|
+
await app.close();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("uses first matching variant when multiple variants match", async () => {
|
|
56
|
+
const app = buildTestServer({
|
|
57
|
+
version: 1,
|
|
58
|
+
settings: baseSettings,
|
|
59
|
+
endpoints: [
|
|
60
|
+
{
|
|
61
|
+
method: "POST",
|
|
62
|
+
path: "/variants",
|
|
63
|
+
response: { ok: true, source: "base" },
|
|
64
|
+
variants: [
|
|
65
|
+
{
|
|
66
|
+
name: "first",
|
|
67
|
+
match: { body: { role: "admin" } },
|
|
68
|
+
status: 403,
|
|
69
|
+
response: { ok: false, source: "first" }
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "second",
|
|
73
|
+
match: { body: { role: "admin" } },
|
|
74
|
+
status: 200,
|
|
75
|
+
response: { ok: true, source: "second" }
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const res = await app.inject({ method: "POST", url: "/variants", payload: { role: "admin" } });
|
|
83
|
+
expect(res.statusCode).toBe(403);
|
|
84
|
+
expect(res.json()).toEqual({ ok: false, source: "first" });
|
|
85
|
+
|
|
86
|
+
await app.close();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("falls back to endpoint response when no variant matches", async () => {
|
|
90
|
+
const app = buildTestServer({
|
|
91
|
+
version: 1,
|
|
92
|
+
settings: baseSettings,
|
|
93
|
+
endpoints: [
|
|
94
|
+
{
|
|
95
|
+
method: "POST",
|
|
96
|
+
path: "/variants",
|
|
97
|
+
response: { ok: true, source: "base" },
|
|
98
|
+
variants: [
|
|
99
|
+
{
|
|
100
|
+
name: "only",
|
|
101
|
+
match: { body: { role: "admin" } },
|
|
102
|
+
status: 201,
|
|
103
|
+
response: { ok: true, source: "variant" }
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const res = await app.inject({ method: "POST", url: "/variants", payload: { role: "user" } });
|
|
111
|
+
expect(res.statusCode).toBe(200);
|
|
112
|
+
expect(res.json()).toEqual({ ok: true, source: "base" });
|
|
113
|
+
|
|
114
|
+
await app.close();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("rejects array query values for match rules", async () => {
|
|
118
|
+
const app = buildTestServer({
|
|
119
|
+
version: 1,
|
|
120
|
+
settings: baseSettings,
|
|
121
|
+
endpoints: [
|
|
122
|
+
{
|
|
123
|
+
method: "GET",
|
|
124
|
+
path: "/search",
|
|
125
|
+
match: { query: { type: "premium" } },
|
|
126
|
+
response: { ok: true }
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const res = await app.inject({ method: "GET", url: "/search?type=premium&type=basic" });
|
|
132
|
+
expect(res.statusCode).toBe(404);
|
|
133
|
+
|
|
134
|
+
await app.close();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("matches request headers (case-insensitive)", async () => {
|
|
138
|
+
const app = buildTestServer({
|
|
139
|
+
version: 1,
|
|
140
|
+
settings: baseSettings,
|
|
141
|
+
endpoints: [
|
|
142
|
+
{
|
|
143
|
+
method: "GET",
|
|
144
|
+
path: "/api/data",
|
|
145
|
+
match: { headers: { Authorization: "Bearer token123" } },
|
|
146
|
+
response: { ok: true, data: "secure" }
|
|
147
|
+
}
|
|
148
|
+
]
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const ok = await app.inject({
|
|
152
|
+
method: "GET",
|
|
153
|
+
url: "/api/data",
|
|
154
|
+
headers: { authorization: "Bearer token123" }
|
|
155
|
+
});
|
|
156
|
+
expect(ok.statusCode).toBe(200);
|
|
157
|
+
expect(ok.json()).toEqual({ ok: true, data: "secure" });
|
|
158
|
+
|
|
159
|
+
const bad = await app.inject({
|
|
160
|
+
method: "GET",
|
|
161
|
+
url: "/api/data",
|
|
162
|
+
headers: { authorization: "Bearer wrong" }
|
|
163
|
+
});
|
|
164
|
+
expect(bad.statusCode).toBe(404);
|
|
165
|
+
|
|
166
|
+
await app.close();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("matches cookies", async () => {
|
|
170
|
+
const app = buildTestServer({
|
|
171
|
+
version: 1,
|
|
172
|
+
settings: baseSettings,
|
|
173
|
+
endpoints: [
|
|
174
|
+
{
|
|
175
|
+
method: "GET",
|
|
176
|
+
path: "/profile",
|
|
177
|
+
match: { cookies: { sessionId: "abc123" } },
|
|
178
|
+
response: { user: "john" }
|
|
179
|
+
}
|
|
180
|
+
]
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const ok = await app.inject({
|
|
184
|
+
method: "GET",
|
|
185
|
+
url: "/profile",
|
|
186
|
+
headers: { cookie: "sessionId=abc123" }
|
|
187
|
+
});
|
|
188
|
+
expect(ok.statusCode).toBe(200);
|
|
189
|
+
expect(ok.json()).toEqual({ user: "john" });
|
|
190
|
+
|
|
191
|
+
const bad = await app.inject({
|
|
192
|
+
method: "GET",
|
|
193
|
+
url: "/profile",
|
|
194
|
+
headers: { cookie: "sessionId=wrong" }
|
|
195
|
+
});
|
|
196
|
+
expect(bad.statusCode).toBe(404);
|
|
197
|
+
|
|
198
|
+
await app.close();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("combines query, body, headers, and cookies in match rules", async () => {
|
|
202
|
+
const app = buildTestServer({
|
|
203
|
+
version: 1,
|
|
204
|
+
settings: baseSettings,
|
|
205
|
+
endpoints: [
|
|
206
|
+
{
|
|
207
|
+
method: "POST",
|
|
208
|
+
path: "/api/action",
|
|
209
|
+
match: {
|
|
210
|
+
query: { type: "premium" },
|
|
211
|
+
body: { action: "delete" },
|
|
212
|
+
headers: { "X-API-Key": "secret" },
|
|
213
|
+
cookies: { session: "valid" }
|
|
214
|
+
},
|
|
215
|
+
response: { ok: true }
|
|
216
|
+
}
|
|
217
|
+
]
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const ok = await app.inject({
|
|
221
|
+
method: "POST",
|
|
222
|
+
url: "/api/action?type=premium",
|
|
223
|
+
headers: {
|
|
224
|
+
"x-api-key": "secret",
|
|
225
|
+
cookie: "session=valid"
|
|
226
|
+
},
|
|
227
|
+
payload: { action: "delete" }
|
|
228
|
+
});
|
|
229
|
+
expect(ok.statusCode).toBe(200);
|
|
230
|
+
|
|
231
|
+
// Missing one of the requirements
|
|
232
|
+
const bad = await app.inject({
|
|
233
|
+
method: "POST",
|
|
234
|
+
url: "/api/action?type=premium",
|
|
235
|
+
headers: {
|
|
236
|
+
"x-api-key": "secret",
|
|
237
|
+
cookie: "session=wrong"
|
|
238
|
+
},
|
|
239
|
+
payload: { action: "delete" }
|
|
240
|
+
});
|
|
241
|
+
expect(bad.statusCode).toBe(404);
|
|
242
|
+
|
|
243
|
+
await app.close();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildTestServerFromFixture, loadFixture } from "./helpers.js";
|
|
3
|
+
|
|
4
|
+
describe("mockserve - core behavior", () => {
|
|
5
|
+
it("renders templates using path params + query", async () => {
|
|
6
|
+
const app = await buildTestServerFromFixture("tests/fixtures/spec.basic.json");
|
|
7
|
+
|
|
8
|
+
const res = await app.inject({
|
|
9
|
+
method: "GET",
|
|
10
|
+
url: "/users/42?type=basic"
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
expect(res.statusCode).toBe(200);
|
|
14
|
+
expect(res.json()).toEqual({ id: "42", type: "basic" });
|
|
15
|
+
|
|
16
|
+
await app.close();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("enforces endpoint-level match rules (query match -> 404 when mismatch)", async () => {
|
|
20
|
+
const app = await buildTestServerFromFixture("tests/fixtures/spec.basic.json");
|
|
21
|
+
|
|
22
|
+
const ok = await app.inject({ method: "GET", url: "/search?type=premium" });
|
|
23
|
+
expect(ok.statusCode).toBe(200);
|
|
24
|
+
|
|
25
|
+
const bad = await app.inject({ method: "GET", url: "/search?type=basic" });
|
|
26
|
+
expect(bad.statusCode).toBe(404);
|
|
27
|
+
|
|
28
|
+
await app.close();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns variant response when variant match is satisfied", async () => {
|
|
32
|
+
const app = await buildTestServerFromFixture("tests/fixtures/spec.basic.json");
|
|
33
|
+
|
|
34
|
+
const res = await app.inject({
|
|
35
|
+
method: "POST",
|
|
36
|
+
url: "/login",
|
|
37
|
+
payload: { email: "a@b.com", password: "wrong" }
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(res.statusCode).toBe(401);
|
|
41
|
+
expect(res.json()).toEqual({ ok: false, error: "Invalid credentials" });
|
|
42
|
+
|
|
43
|
+
await app.close();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("falls back to base endpoint response when no variant matches", async () => {
|
|
47
|
+
const app = await buildTestServerFromFixture("tests/fixtures/spec.basic.json");
|
|
48
|
+
|
|
49
|
+
const res = await app.inject({
|
|
50
|
+
method: "POST",
|
|
51
|
+
url: "/login",
|
|
52
|
+
payload: { email: "a@b.com", password: "ok" }
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(res.statusCode).toBe(200);
|
|
56
|
+
expect(res.json()).toEqual({ ok: true, message: "default" });
|
|
57
|
+
|
|
58
|
+
await app.close();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("exposes /__spec for debugging", async () => {
|
|
62
|
+
const app = await buildTestServerFromFixture("tests/fixtures/spec.basic.json");
|
|
63
|
+
|
|
64
|
+
const res = await app.inject({ method: "GET", url: "/__spec" });
|
|
65
|
+
|
|
66
|
+
expect(res.statusCode).toBe(200);
|
|
67
|
+
const body = res.json();
|
|
68
|
+
expect(body.spec.version).toBe(1);
|
|
69
|
+
expect(body.meta.specPath).toBe("tests/fixtures/spec.basic.json");
|
|
70
|
+
|
|
71
|
+
await app.close();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
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("template rendering", () => {
|
|
12
|
+
it("renders params and query placeholders in responses", async () => {
|
|
13
|
+
const app = buildTestServer({
|
|
14
|
+
version: 1,
|
|
15
|
+
settings: baseSettings,
|
|
16
|
+
endpoints: [
|
|
17
|
+
{
|
|
18
|
+
method: "GET",
|
|
19
|
+
path: "/users/:id",
|
|
20
|
+
response: {
|
|
21
|
+
id: "{{params.id}}",
|
|
22
|
+
type: "{{query.type}}",
|
|
23
|
+
nested: { tag: "{{query.tag}}" }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const res = await app.inject({ method: "GET", url: "/users/42?type=basic&tag=vip" });
|
|
30
|
+
expect(res.statusCode).toBe(200);
|
|
31
|
+
expect(res.json()).toEqual({ id: "42", type: "basic", nested: { tag: "vip" } });
|
|
32
|
+
|
|
33
|
+
await app.close();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("renders body placeholders in responses", async () => {
|
|
37
|
+
const app = buildTestServer({
|
|
38
|
+
version: 1,
|
|
39
|
+
settings: baseSettings,
|
|
40
|
+
endpoints: [
|
|
41
|
+
{
|
|
42
|
+
method: "POST",
|
|
43
|
+
path: "/echo",
|
|
44
|
+
response: {
|
|
45
|
+
email: "{{body.email}}",
|
|
46
|
+
name: "{{body.profile.name}}"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const res = await app.inject({
|
|
53
|
+
method: "POST",
|
|
54
|
+
url: "/echo",
|
|
55
|
+
payload: { email: "user@example.com", profile: { name: "Mia" } }
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(res.statusCode).toBe(200);
|
|
59
|
+
expect(res.json()).toEqual({ email: "user@example.com", name: "Mia" });
|
|
60
|
+
|
|
61
|
+
await app.close();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("renders arrays of templated items", async () => {
|
|
65
|
+
const app = buildTestServer({
|
|
66
|
+
version: 1,
|
|
67
|
+
settings: baseSettings,
|
|
68
|
+
endpoints: [
|
|
69
|
+
{
|
|
70
|
+
method: "GET",
|
|
71
|
+
path: "/tags/:id",
|
|
72
|
+
response: {
|
|
73
|
+
id: "{{params.id}}",
|
|
74
|
+
tags: ["{{query.primary}}", "{{query.secondary}}"]
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const res = await app.inject({
|
|
81
|
+
method: "GET",
|
|
82
|
+
url: "/tags/abc?primary=gold&secondary=silver"
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(res.statusCode).toBe(200);
|
|
86
|
+
expect(res.json()).toEqual({ id: "abc", tags: ["gold", "silver"] });
|
|
87
|
+
|
|
88
|
+
await app.close();
|
|
89
|
+
});
|
|
90
|
+
});
|
package/src/template.ts
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
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
|
-
}
|