@typokit/server-native 0.1.4
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.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +327 -0
- package/dist/index.js.map +1 -0
- package/package.json +33 -0
- package/src/index.test.ts +1303 -0
- package/src/index.ts +450 -0
|
@@ -0,0 +1,1303 @@
|
|
|
1
|
+
// @typokit/server-native — Integration Tests
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from "@rstest/core";
|
|
4
|
+
import type {
|
|
5
|
+
CompiledRoute,
|
|
6
|
+
CompiledRouteTable,
|
|
7
|
+
ErrorResponse,
|
|
8
|
+
HandlerMap,
|
|
9
|
+
MiddlewareChain,
|
|
10
|
+
SerializerMap,
|
|
11
|
+
TypoKitRequest,
|
|
12
|
+
ValidatorMap,
|
|
13
|
+
} from "@typokit/types";
|
|
14
|
+
import type { Server } from "node:http";
|
|
15
|
+
import {
|
|
16
|
+
nativeServer,
|
|
17
|
+
runValidators,
|
|
18
|
+
serializeResponse,
|
|
19
|
+
validationErrorResponse,
|
|
20
|
+
} from "./index.js";
|
|
21
|
+
|
|
22
|
+
// ─── Test Helpers ────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function makeRouteTable(): CompiledRouteTable {
|
|
25
|
+
// Route tree:
|
|
26
|
+
// / -> GET
|
|
27
|
+
// /users -> GET, POST
|
|
28
|
+
// /users/:id -> GET, PUT, DELETE
|
|
29
|
+
// /posts/:id/comments -> GET
|
|
30
|
+
const root: CompiledRoute = {
|
|
31
|
+
segment: "",
|
|
32
|
+
handlers: {
|
|
33
|
+
GET: { ref: "root#index", middleware: [] },
|
|
34
|
+
},
|
|
35
|
+
children: {
|
|
36
|
+
users: {
|
|
37
|
+
segment: "users",
|
|
38
|
+
handlers: {
|
|
39
|
+
GET: { ref: "users#list", middleware: [] },
|
|
40
|
+
POST: { ref: "users#create", middleware: [] },
|
|
41
|
+
},
|
|
42
|
+
paramChild: {
|
|
43
|
+
segment: ":id",
|
|
44
|
+
paramName: "id",
|
|
45
|
+
handlers: {
|
|
46
|
+
GET: { ref: "users#get", middleware: [] },
|
|
47
|
+
PUT: { ref: "users#update", middleware: [] },
|
|
48
|
+
DELETE: { ref: "users#delete", middleware: [] },
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
posts: {
|
|
53
|
+
segment: "posts",
|
|
54
|
+
paramChild: {
|
|
55
|
+
segment: ":id",
|
|
56
|
+
paramName: "id",
|
|
57
|
+
children: {
|
|
58
|
+
comments: {
|
|
59
|
+
segment: "comments",
|
|
60
|
+
handlers: {
|
|
61
|
+
GET: { ref: "comments#list", middleware: [] },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
return root;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Route table with validator references */
|
|
73
|
+
function makeValidatedRouteTable(): CompiledRouteTable {
|
|
74
|
+
const root: CompiledRoute = {
|
|
75
|
+
segment: "",
|
|
76
|
+
children: {
|
|
77
|
+
users: {
|
|
78
|
+
segment: "users",
|
|
79
|
+
handlers: {
|
|
80
|
+
GET: {
|
|
81
|
+
ref: "users#list",
|
|
82
|
+
middleware: [],
|
|
83
|
+
validators: { query: "ListUsersQuery" },
|
|
84
|
+
},
|
|
85
|
+
POST: {
|
|
86
|
+
ref: "users#create",
|
|
87
|
+
middleware: [],
|
|
88
|
+
validators: { body: "CreateUserBody" },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
paramChild: {
|
|
92
|
+
segment: ":id",
|
|
93
|
+
paramName: "id",
|
|
94
|
+
handlers: {
|
|
95
|
+
GET: {
|
|
96
|
+
ref: "users#get",
|
|
97
|
+
middleware: [],
|
|
98
|
+
validators: { params: "UserIdParams" },
|
|
99
|
+
},
|
|
100
|
+
PUT: {
|
|
101
|
+
ref: "users#update",
|
|
102
|
+
middleware: [],
|
|
103
|
+
validators: {
|
|
104
|
+
params: "UserIdParams",
|
|
105
|
+
body: "UpdateUserBody",
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
return root;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function makeHandlerMap(): HandlerMap {
|
|
117
|
+
return {
|
|
118
|
+
"root#index": async () => ({
|
|
119
|
+
status: 200,
|
|
120
|
+
headers: {},
|
|
121
|
+
body: { message: "Welcome" },
|
|
122
|
+
}),
|
|
123
|
+
"users#list": async (req: TypoKitRequest) => ({
|
|
124
|
+
status: 200,
|
|
125
|
+
headers: {},
|
|
126
|
+
body: { users: [], query: req.query },
|
|
127
|
+
}),
|
|
128
|
+
"users#create": async (req: TypoKitRequest) => ({
|
|
129
|
+
status: 201,
|
|
130
|
+
headers: {},
|
|
131
|
+
body: { created: true, data: req.body },
|
|
132
|
+
}),
|
|
133
|
+
"users#get": async (req: TypoKitRequest) => ({
|
|
134
|
+
status: 200,
|
|
135
|
+
headers: {},
|
|
136
|
+
body: { id: req.params.id },
|
|
137
|
+
}),
|
|
138
|
+
"users#update": async (req: TypoKitRequest) => ({
|
|
139
|
+
status: 200,
|
|
140
|
+
headers: {},
|
|
141
|
+
body: { updated: req.params.id, data: req.body },
|
|
142
|
+
}),
|
|
143
|
+
"users#delete": async (_req: TypoKitRequest) => ({
|
|
144
|
+
status: 204,
|
|
145
|
+
headers: {},
|
|
146
|
+
body: null,
|
|
147
|
+
}),
|
|
148
|
+
"comments#list": async (req: TypoKitRequest) => ({
|
|
149
|
+
status: 200,
|
|
150
|
+
headers: {},
|
|
151
|
+
body: { postId: req.params.id, comments: [] },
|
|
152
|
+
}),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function makeValidatorMap(): ValidatorMap {
|
|
157
|
+
return {
|
|
158
|
+
UserIdParams: (input) => {
|
|
159
|
+
const obj = input as Record<string, unknown>;
|
|
160
|
+
const errors = [];
|
|
161
|
+
if (typeof obj.id !== "string" || !/^\d+$/.test(obj.id)) {
|
|
162
|
+
errors.push({ path: "id", expected: "numeric string", actual: obj.id });
|
|
163
|
+
}
|
|
164
|
+
return errors.length === 0
|
|
165
|
+
? { success: true, data: input }
|
|
166
|
+
: { success: false, errors };
|
|
167
|
+
},
|
|
168
|
+
ListUsersQuery: (input) => {
|
|
169
|
+
const obj = input as Record<string, unknown>;
|
|
170
|
+
const errors = [];
|
|
171
|
+
if (obj.page !== undefined && typeof obj.page !== "string") {
|
|
172
|
+
errors.push({
|
|
173
|
+
path: "page",
|
|
174
|
+
expected: "string",
|
|
175
|
+
actual: typeof obj.page,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (obj.limit !== undefined) {
|
|
179
|
+
const limit = Number(obj.limit);
|
|
180
|
+
if (isNaN(limit) || limit < 1 || limit > 100) {
|
|
181
|
+
errors.push({ path: "limit", expected: "1-100", actual: obj.limit });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return errors.length === 0
|
|
185
|
+
? { success: true, data: input }
|
|
186
|
+
: { success: false, errors };
|
|
187
|
+
},
|
|
188
|
+
CreateUserBody: (input) => {
|
|
189
|
+
const obj = input as Record<string, unknown>;
|
|
190
|
+
const errors = [];
|
|
191
|
+
if (typeof obj !== "object" || obj === null) {
|
|
192
|
+
return {
|
|
193
|
+
success: false,
|
|
194
|
+
errors: [
|
|
195
|
+
{ path: "$input", expected: "object", actual: typeof input },
|
|
196
|
+
],
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
if (typeof obj.name !== "string" || obj.name.length === 0) {
|
|
200
|
+
errors.push({
|
|
201
|
+
path: "name",
|
|
202
|
+
expected: "non-empty string",
|
|
203
|
+
actual: obj.name,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (typeof obj.email !== "string" || !obj.email.includes("@")) {
|
|
207
|
+
errors.push({
|
|
208
|
+
path: "email",
|
|
209
|
+
expected: "valid email",
|
|
210
|
+
actual: obj.email,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return errors.length === 0
|
|
214
|
+
? { success: true, data: input }
|
|
215
|
+
: { success: false, errors };
|
|
216
|
+
},
|
|
217
|
+
UpdateUserBody: (input) => {
|
|
218
|
+
const obj = input as Record<string, unknown>;
|
|
219
|
+
const errors = [];
|
|
220
|
+
if (typeof obj !== "object" || obj === null) {
|
|
221
|
+
return {
|
|
222
|
+
success: false,
|
|
223
|
+
errors: [
|
|
224
|
+
{ path: "$input", expected: "object", actual: typeof input },
|
|
225
|
+
],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (obj.name !== undefined && typeof obj.name !== "string") {
|
|
229
|
+
errors.push({
|
|
230
|
+
path: "name",
|
|
231
|
+
expected: "string",
|
|
232
|
+
actual: typeof obj.name,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
if (
|
|
236
|
+
obj.email !== undefined &&
|
|
237
|
+
(typeof obj.email !== "string" || !obj.email.includes("@"))
|
|
238
|
+
) {
|
|
239
|
+
errors.push({
|
|
240
|
+
path: "email",
|
|
241
|
+
expected: "valid email",
|
|
242
|
+
actual: obj.email,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
return errors.length === 0
|
|
246
|
+
? { success: true, data: input }
|
|
247
|
+
: { success: false, errors };
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const emptyMiddleware: MiddlewareChain = { entries: [] };
|
|
253
|
+
|
|
254
|
+
async function fetchJson(
|
|
255
|
+
port: number,
|
|
256
|
+
path: string,
|
|
257
|
+
options: { method?: string; body?: unknown } = {},
|
|
258
|
+
): Promise<{ status: number; headers: Record<string, string>; body: unknown }> {
|
|
259
|
+
const method = options.method ?? "GET";
|
|
260
|
+
const headers: Record<string, string> = {};
|
|
261
|
+
let bodyStr: string | undefined;
|
|
262
|
+
|
|
263
|
+
if (options.body !== undefined) {
|
|
264
|
+
headers["content-type"] = "application/json";
|
|
265
|
+
bodyStr = JSON.stringify(options.body);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const res = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
269
|
+
method,
|
|
270
|
+
headers,
|
|
271
|
+
body: bodyStr,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const resHeaders: Record<string, string> = {};
|
|
275
|
+
res.headers.forEach((v, k) => {
|
|
276
|
+
resHeaders[k] = v;
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
let body: unknown;
|
|
280
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
281
|
+
if (ct.includes("application/json")) {
|
|
282
|
+
body = await res.json();
|
|
283
|
+
} else {
|
|
284
|
+
const text = await res.text();
|
|
285
|
+
body = text || null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { status: res.status, headers: resHeaders, body };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ─── Unit Tests for Validation Helpers ───────────────────────
|
|
292
|
+
|
|
293
|
+
describe("validationErrorResponse", () => {
|
|
294
|
+
it("produces a 400 response with field-level errors", () => {
|
|
295
|
+
const res = validationErrorResponse("Validation failed", [
|
|
296
|
+
{ path: "body.name", expected: "string", actual: 42 },
|
|
297
|
+
]);
|
|
298
|
+
expect(res.status).toBe(400);
|
|
299
|
+
const body = res.body as ErrorResponse;
|
|
300
|
+
expect(body.error.code).toBe("VALIDATION_ERROR");
|
|
301
|
+
expect(body.error.message).toBe("Validation failed");
|
|
302
|
+
const fields = body.error.details?.fields as Array<{ path: string }>;
|
|
303
|
+
expect(fields).toHaveLength(1);
|
|
304
|
+
expect(fields[0].path).toBe("body.name");
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe("runValidators", () => {
|
|
309
|
+
it("returns undefined when no validators configured", () => {
|
|
310
|
+
const result = runValidators({ validators: undefined }, null, {}, {}, null);
|
|
311
|
+
expect(result).toBeUndefined();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("returns undefined when no validatorMap provided", () => {
|
|
315
|
+
const result = runValidators(
|
|
316
|
+
{ validators: { body: "SomeValidator" } },
|
|
317
|
+
null,
|
|
318
|
+
{},
|
|
319
|
+
{},
|
|
320
|
+
null,
|
|
321
|
+
);
|
|
322
|
+
expect(result).toBeUndefined();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("returns undefined when validator ref not found in map", () => {
|
|
326
|
+
const result = runValidators(
|
|
327
|
+
{ validators: { body: "MissingValidator" } },
|
|
328
|
+
{},
|
|
329
|
+
{},
|
|
330
|
+
{},
|
|
331
|
+
null,
|
|
332
|
+
);
|
|
333
|
+
expect(result).toBeUndefined();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("returns undefined when all validators pass", () => {
|
|
337
|
+
const validators: ValidatorMap = {
|
|
338
|
+
BodyValidator: () => ({ success: true, data: {} }),
|
|
339
|
+
};
|
|
340
|
+
const result = runValidators(
|
|
341
|
+
{ validators: { body: "BodyValidator" } },
|
|
342
|
+
validators,
|
|
343
|
+
{},
|
|
344
|
+
{},
|
|
345
|
+
{ name: "Alice" },
|
|
346
|
+
);
|
|
347
|
+
expect(result).toBeUndefined();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("returns 400 response when body validator fails", () => {
|
|
351
|
+
const validators: ValidatorMap = {
|
|
352
|
+
BodyValidator: () => ({
|
|
353
|
+
success: false,
|
|
354
|
+
errors: [{ path: "name", expected: "string", actual: undefined }],
|
|
355
|
+
}),
|
|
356
|
+
};
|
|
357
|
+
const result = runValidators(
|
|
358
|
+
{ validators: { body: "BodyValidator" } },
|
|
359
|
+
validators,
|
|
360
|
+
{},
|
|
361
|
+
{},
|
|
362
|
+
{},
|
|
363
|
+
);
|
|
364
|
+
expect(result).toBeDefined();
|
|
365
|
+
expect(result!.status).toBe(400);
|
|
366
|
+
const body = result!.body as ErrorResponse;
|
|
367
|
+
expect(body.error.code).toBe("VALIDATION_ERROR");
|
|
368
|
+
const fields = body.error.details?.fields as Array<{ path: string }>;
|
|
369
|
+
expect(fields[0].path).toBe("body.name");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("prefixes param errors with params.", () => {
|
|
373
|
+
const validators: ValidatorMap = {
|
|
374
|
+
ParamVal: () => ({
|
|
375
|
+
success: false,
|
|
376
|
+
errors: [{ path: "id", expected: "numeric", actual: "abc" }],
|
|
377
|
+
}),
|
|
378
|
+
};
|
|
379
|
+
const result = runValidators(
|
|
380
|
+
{ validators: { params: "ParamVal" } },
|
|
381
|
+
validators,
|
|
382
|
+
{ id: "abc" },
|
|
383
|
+
{},
|
|
384
|
+
null,
|
|
385
|
+
);
|
|
386
|
+
expect(result).toBeDefined();
|
|
387
|
+
const fields = (result!.body as ErrorResponse).error.details
|
|
388
|
+
?.fields as Array<{ path: string }>;
|
|
389
|
+
expect(fields[0].path).toBe("params.id");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("prefixes query errors with query.", () => {
|
|
393
|
+
const validators: ValidatorMap = {
|
|
394
|
+
QueryVal: () => ({
|
|
395
|
+
success: false,
|
|
396
|
+
errors: [{ path: "limit", expected: "number", actual: "abc" }],
|
|
397
|
+
}),
|
|
398
|
+
};
|
|
399
|
+
const result = runValidators(
|
|
400
|
+
{ validators: { query: "QueryVal" } },
|
|
401
|
+
validators,
|
|
402
|
+
{},
|
|
403
|
+
{ limit: "abc" },
|
|
404
|
+
null,
|
|
405
|
+
);
|
|
406
|
+
expect(result).toBeDefined();
|
|
407
|
+
const fields = (result!.body as ErrorResponse).error.details
|
|
408
|
+
?.fields as Array<{ path: string }>;
|
|
409
|
+
expect(fields[0].path).toBe("query.limit");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("aggregates errors from multiple validators", () => {
|
|
413
|
+
const validators: ValidatorMap = {
|
|
414
|
+
ParamVal: () => ({
|
|
415
|
+
success: false,
|
|
416
|
+
errors: [{ path: "id", expected: "numeric", actual: "abc" }],
|
|
417
|
+
}),
|
|
418
|
+
BodyVal: () => ({
|
|
419
|
+
success: false,
|
|
420
|
+
errors: [{ path: "name", expected: "string", actual: 42 }],
|
|
421
|
+
}),
|
|
422
|
+
};
|
|
423
|
+
const result = runValidators(
|
|
424
|
+
{ validators: { params: "ParamVal", body: "BodyVal" } },
|
|
425
|
+
validators,
|
|
426
|
+
{ id: "abc" },
|
|
427
|
+
{},
|
|
428
|
+
{ name: 42 },
|
|
429
|
+
);
|
|
430
|
+
expect(result).toBeDefined();
|
|
431
|
+
const fields = (result!.body as ErrorResponse).error.details
|
|
432
|
+
?.fields as Array<{ path: string }>;
|
|
433
|
+
expect(fields).toHaveLength(2);
|
|
434
|
+
expect(fields[0].path).toBe("params.id");
|
|
435
|
+
expect(fields[1].path).toBe("body.name");
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// ─── Original Tests ──────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
describe("nativeServer", () => {
|
|
442
|
+
it("creates a server adapter with correct name", () => {
|
|
443
|
+
const adapter = nativeServer();
|
|
444
|
+
expect(adapter.name).toBe("native");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("implements the ServerAdapter interface", () => {
|
|
448
|
+
const adapter = nativeServer();
|
|
449
|
+
expect(typeof adapter.registerRoutes).toBe("function");
|
|
450
|
+
expect(typeof adapter.listen).toBe("function");
|
|
451
|
+
expect(typeof adapter.normalizeRequest).toBe("function");
|
|
452
|
+
expect(typeof adapter.writeResponse).toBe("function");
|
|
453
|
+
expect(typeof adapter.getNativeServer).toBe("function");
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
describe("nativeServer integration", () => {
|
|
458
|
+
it("routes GET / to root handler", async () => {
|
|
459
|
+
const adapter = nativeServer();
|
|
460
|
+
adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
|
|
461
|
+
const handle = await adapter.listen(0);
|
|
462
|
+
try {
|
|
463
|
+
const server = adapter.getNativeServer!() as Server;
|
|
464
|
+
const addr = server.address() as { port: number };
|
|
465
|
+
const res = await fetchJson(addr.port, "/");
|
|
466
|
+
expect(res.status).toBe(200);
|
|
467
|
+
expect((res.body as Record<string, unknown>).message).toBe("Welcome");
|
|
468
|
+
} finally {
|
|
469
|
+
await handle.close();
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("routes GET /users to list handler", async () => {
|
|
474
|
+
const adapter = nativeServer();
|
|
475
|
+
adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
|
|
476
|
+
const handle = await adapter.listen(0);
|
|
477
|
+
try {
|
|
478
|
+
const server = adapter.getNativeServer!() as Server;
|
|
479
|
+
const addr = server.address() as { port: number };
|
|
480
|
+
const res = await fetchJson(addr.port, "/users");
|
|
481
|
+
expect(res.status).toBe(200);
|
|
482
|
+
expect((res.body as Record<string, unknown>).users).toEqual([]);
|
|
483
|
+
} finally {
|
|
484
|
+
await handle.close();
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("extracts route params from /users/:id", async () => {
|
|
489
|
+
const adapter = nativeServer();
|
|
490
|
+
adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
|
|
491
|
+
const handle = await adapter.listen(0);
|
|
492
|
+
try {
|
|
493
|
+
const server = adapter.getNativeServer!() as Server;
|
|
494
|
+
const addr = server.address() as { port: number };
|
|
495
|
+
const res = await fetchJson(addr.port, "/users/42");
|
|
496
|
+
expect(res.status).toBe(200);
|
|
497
|
+
expect((res.body as Record<string, unknown>).id).toBe("42");
|
|
498
|
+
} finally {
|
|
499
|
+
await handle.close();
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("handles POST /users with body", async () => {
|
|
504
|
+
const adapter = nativeServer();
|
|
505
|
+
adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
|
|
506
|
+
const handle = await adapter.listen(0);
|
|
507
|
+
try {
|
|
508
|
+
const server = adapter.getNativeServer!() as Server;
|
|
509
|
+
const addr = server.address() as { port: number };
|
|
510
|
+
const res = await fetchJson(addr.port, "/users", {
|
|
511
|
+
method: "POST",
|
|
512
|
+
body: { name: "Alice" },
|
|
513
|
+
});
|
|
514
|
+
expect(res.status).toBe(201);
|
|
515
|
+
const b = res.body as Record<string, unknown>;
|
|
516
|
+
expect(b.created).toBe(true);
|
|
517
|
+
expect((b.data as Record<string, unknown>).name).toBe("Alice");
|
|
518
|
+
} finally {
|
|
519
|
+
await handle.close();
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("handles nested param routes: /posts/:id/comments", async () => {
|
|
524
|
+
const adapter = nativeServer();
|
|
525
|
+
adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
|
|
526
|
+
const handle = await adapter.listen(0);
|
|
527
|
+
try {
|
|
528
|
+
const server = adapter.getNativeServer!() as Server;
|
|
529
|
+
const addr = server.address() as { port: number };
|
|
530
|
+
const res = await fetchJson(addr.port, "/posts/99/comments");
|
|
531
|
+
expect(res.status).toBe(200);
|
|
532
|
+
const b = res.body as Record<string, unknown>;
|
|
533
|
+
expect(b.postId).toBe("99");
|
|
534
|
+
expect(b.comments).toEqual([]);
|
|
535
|
+
} finally {
|
|
536
|
+
await handle.close();
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("returns 404 for unknown routes", async () => {
|
|
541
|
+
const adapter = nativeServer();
|
|
542
|
+
adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
|
|
543
|
+
const handle = await adapter.listen(0);
|
|
544
|
+
try {
|
|
545
|
+
const server = adapter.getNativeServer!() as Server;
|
|
546
|
+
const addr = server.address() as { port: number };
|
|
547
|
+
const res = await fetchJson(addr.port, "/nonexistent");
|
|
548
|
+
expect(res.status).toBe(404);
|
|
549
|
+
expect((res.body as Record<string, unknown>).error).toBe("Not Found");
|
|
550
|
+
} finally {
|
|
551
|
+
await handle.close();
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("returns 405 with Allow header for wrong method", async () => {
|
|
556
|
+
const adapter = nativeServer();
|
|
557
|
+
adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
|
|
558
|
+
const handle = await adapter.listen(0);
|
|
559
|
+
try {
|
|
560
|
+
const server = adapter.getNativeServer!() as Server;
|
|
561
|
+
const addr = server.address() as { port: number };
|
|
562
|
+
const res = await fetchJson(addr.port, "/users", { method: "PATCH" });
|
|
563
|
+
expect(res.status).toBe(405);
|
|
564
|
+
expect(res.headers["allow"]).toBeDefined();
|
|
565
|
+
expect(res.headers["allow"]).toContain("GET");
|
|
566
|
+
expect(res.headers["allow"]).toContain("POST");
|
|
567
|
+
} finally {
|
|
568
|
+
await handle.close();
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("normalizes trailing slashes: /users/ matches /users", async () => {
|
|
573
|
+
const adapter = nativeServer();
|
|
574
|
+
adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
|
|
575
|
+
const handle = await adapter.listen(0);
|
|
576
|
+
try {
|
|
577
|
+
const server = adapter.getNativeServer!() as Server;
|
|
578
|
+
const addr = server.address() as { port: number };
|
|
579
|
+
const res = await fetchJson(addr.port, "/users/");
|
|
580
|
+
expect(res.status).toBe(200);
|
|
581
|
+
expect((res.body as Record<string, unknown>).users).toEqual([]);
|
|
582
|
+
} finally {
|
|
583
|
+
await handle.close();
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("getNativeServer returns the underlying http.Server", async () => {
|
|
588
|
+
const adapter = nativeServer();
|
|
589
|
+
adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
|
|
590
|
+
const handle = await adapter.listen(0);
|
|
591
|
+
try {
|
|
592
|
+
const server = adapter.getNativeServer!();
|
|
593
|
+
expect(server).toBeDefined();
|
|
594
|
+
expect(typeof (server as Record<string, unknown>).listen).toBe(
|
|
595
|
+
"function",
|
|
596
|
+
);
|
|
597
|
+
} finally {
|
|
598
|
+
await handle.close();
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("normalizeRequest creates TypoKitRequest from raw object", () => {
|
|
603
|
+
const adapter = nativeServer();
|
|
604
|
+
const raw = {
|
|
605
|
+
method: "GET" as const,
|
|
606
|
+
path: "/test",
|
|
607
|
+
headers: { "x-foo": "bar" },
|
|
608
|
+
body: null,
|
|
609
|
+
query: { q: "hello" },
|
|
610
|
+
params: { id: "1" },
|
|
611
|
+
};
|
|
612
|
+
const req = adapter.normalizeRequest(raw);
|
|
613
|
+
expect(req.method).toBe("GET");
|
|
614
|
+
expect(req.path).toBe("/test");
|
|
615
|
+
expect(req.headers["x-foo"]).toBe("bar");
|
|
616
|
+
expect(req.query.q).toBe("hello");
|
|
617
|
+
expect(req.params.id).toBe("1");
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("handles DELETE /users/:id", async () => {
|
|
621
|
+
const adapter = nativeServer();
|
|
622
|
+
adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
|
|
623
|
+
const handle = await adapter.listen(0);
|
|
624
|
+
try {
|
|
625
|
+
const server = adapter.getNativeServer!() as Server;
|
|
626
|
+
const addr = server.address() as { port: number };
|
|
627
|
+
const res = await fetchJson(addr.port, "/users/5", { method: "DELETE" });
|
|
628
|
+
expect(res.status).toBe(204);
|
|
629
|
+
} finally {
|
|
630
|
+
await handle.close();
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it("handles PUT /users/:id with body", async () => {
|
|
635
|
+
const adapter = nativeServer();
|
|
636
|
+
adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
|
|
637
|
+
const handle = await adapter.listen(0);
|
|
638
|
+
try {
|
|
639
|
+
const server = adapter.getNativeServer!() as Server;
|
|
640
|
+
const addr = server.address() as { port: number };
|
|
641
|
+
const res = await fetchJson(addr.port, "/users/7", {
|
|
642
|
+
method: "PUT",
|
|
643
|
+
body: { name: "Updated" },
|
|
644
|
+
});
|
|
645
|
+
expect(res.status).toBe(200);
|
|
646
|
+
const b = res.body as Record<string, unknown>;
|
|
647
|
+
expect(b.updated).toBe("7");
|
|
648
|
+
expect((b.data as Record<string, unknown>).name).toBe("Updated");
|
|
649
|
+
} finally {
|
|
650
|
+
await handle.close();
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// ─── Validation Pipeline Integration Tests ───────────────────
|
|
656
|
+
|
|
657
|
+
describe("validation pipeline integration", () => {
|
|
658
|
+
it("valid POST request passes through validators to handler", async () => {
|
|
659
|
+
const adapter = nativeServer();
|
|
660
|
+
adapter.registerRoutes(
|
|
661
|
+
makeValidatedRouteTable(),
|
|
662
|
+
makeHandlerMap(),
|
|
663
|
+
emptyMiddleware,
|
|
664
|
+
makeValidatorMap(),
|
|
665
|
+
);
|
|
666
|
+
const handle = await adapter.listen(0);
|
|
667
|
+
try {
|
|
668
|
+
const server = adapter.getNativeServer!() as Server;
|
|
669
|
+
const addr = server.address() as { port: number };
|
|
670
|
+
const res = await fetchJson(addr.port, "/users", {
|
|
671
|
+
method: "POST",
|
|
672
|
+
body: { name: "Alice", email: "alice@example.com" },
|
|
673
|
+
});
|
|
674
|
+
expect(res.status).toBe(201);
|
|
675
|
+
const b = res.body as Record<string, unknown>;
|
|
676
|
+
expect(b.created).toBe(true);
|
|
677
|
+
expect((b.data as Record<string, unknown>).name).toBe("Alice");
|
|
678
|
+
} finally {
|
|
679
|
+
await handle.close();
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it("invalid POST body returns 400 with field errors", async () => {
|
|
684
|
+
const adapter = nativeServer();
|
|
685
|
+
adapter.registerRoutes(
|
|
686
|
+
makeValidatedRouteTable(),
|
|
687
|
+
makeHandlerMap(),
|
|
688
|
+
emptyMiddleware,
|
|
689
|
+
makeValidatorMap(),
|
|
690
|
+
);
|
|
691
|
+
const handle = await adapter.listen(0);
|
|
692
|
+
try {
|
|
693
|
+
const server = adapter.getNativeServer!() as Server;
|
|
694
|
+
const addr = server.address() as { port: number };
|
|
695
|
+
const res = await fetchJson(addr.port, "/users", {
|
|
696
|
+
method: "POST",
|
|
697
|
+
body: { name: "", email: "not-an-email" },
|
|
698
|
+
});
|
|
699
|
+
expect(res.status).toBe(400);
|
|
700
|
+
const body = res.body as ErrorResponse;
|
|
701
|
+
expect(body.error.code).toBe("VALIDATION_ERROR");
|
|
702
|
+
expect(body.error.message).toBe("Request validation failed");
|
|
703
|
+
const fields = body.error.details?.fields as Array<{
|
|
704
|
+
path: string;
|
|
705
|
+
expected: string;
|
|
706
|
+
}>;
|
|
707
|
+
expect(fields.length).toBeGreaterThan(0);
|
|
708
|
+
// All body errors should be prefixed with "body."
|
|
709
|
+
for (const f of fields) {
|
|
710
|
+
expect(f.path.startsWith("body.")).toBe(true);
|
|
711
|
+
}
|
|
712
|
+
} finally {
|
|
713
|
+
await handle.close();
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it("invalid path params return 400 with field errors", async () => {
|
|
718
|
+
const adapter = nativeServer();
|
|
719
|
+
adapter.registerRoutes(
|
|
720
|
+
makeValidatedRouteTable(),
|
|
721
|
+
makeHandlerMap(),
|
|
722
|
+
emptyMiddleware,
|
|
723
|
+
makeValidatorMap(),
|
|
724
|
+
);
|
|
725
|
+
const handle = await adapter.listen(0);
|
|
726
|
+
try {
|
|
727
|
+
const server = adapter.getNativeServer!() as Server;
|
|
728
|
+
const addr = server.address() as { port: number };
|
|
729
|
+
// "abc" is not a numeric string
|
|
730
|
+
const res = await fetchJson(addr.port, "/users/abc");
|
|
731
|
+
expect(res.status).toBe(400);
|
|
732
|
+
const body = res.body as ErrorResponse;
|
|
733
|
+
expect(body.error.code).toBe("VALIDATION_ERROR");
|
|
734
|
+
const fields = body.error.details?.fields as Array<{ path: string }>;
|
|
735
|
+
expect(fields.some((f) => f.path === "params.id")).toBe(true);
|
|
736
|
+
} finally {
|
|
737
|
+
await handle.close();
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("valid path params pass through to handler", async () => {
|
|
742
|
+
const adapter = nativeServer();
|
|
743
|
+
adapter.registerRoutes(
|
|
744
|
+
makeValidatedRouteTable(),
|
|
745
|
+
makeHandlerMap(),
|
|
746
|
+
emptyMiddleware,
|
|
747
|
+
makeValidatorMap(),
|
|
748
|
+
);
|
|
749
|
+
const handle = await adapter.listen(0);
|
|
750
|
+
try {
|
|
751
|
+
const server = adapter.getNativeServer!() as Server;
|
|
752
|
+
const addr = server.address() as { port: number };
|
|
753
|
+
const res = await fetchJson(addr.port, "/users/42");
|
|
754
|
+
expect(res.status).toBe(200);
|
|
755
|
+
expect((res.body as Record<string, unknown>).id).toBe("42");
|
|
756
|
+
} finally {
|
|
757
|
+
await handle.close();
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it("invalid query params return 400 with field errors", async () => {
|
|
762
|
+
const adapter = nativeServer();
|
|
763
|
+
adapter.registerRoutes(
|
|
764
|
+
makeValidatedRouteTable(),
|
|
765
|
+
makeHandlerMap(),
|
|
766
|
+
emptyMiddleware,
|
|
767
|
+
makeValidatorMap(),
|
|
768
|
+
);
|
|
769
|
+
const handle = await adapter.listen(0);
|
|
770
|
+
try {
|
|
771
|
+
const server = adapter.getNativeServer!() as Server;
|
|
772
|
+
const addr = server.address() as { port: number };
|
|
773
|
+
const res = await fetchJson(addr.port, "/users?limit=999");
|
|
774
|
+
expect(res.status).toBe(400);
|
|
775
|
+
const body = res.body as ErrorResponse;
|
|
776
|
+
expect(body.error.code).toBe("VALIDATION_ERROR");
|
|
777
|
+
const fields = body.error.details?.fields as Array<{ path: string }>;
|
|
778
|
+
expect(fields.some((f) => f.path === "query.limit")).toBe(true);
|
|
779
|
+
} finally {
|
|
780
|
+
await handle.close();
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("valid query params pass through to handler", async () => {
|
|
785
|
+
const adapter = nativeServer();
|
|
786
|
+
adapter.registerRoutes(
|
|
787
|
+
makeValidatedRouteTable(),
|
|
788
|
+
makeHandlerMap(),
|
|
789
|
+
emptyMiddleware,
|
|
790
|
+
makeValidatorMap(),
|
|
791
|
+
);
|
|
792
|
+
const handle = await adapter.listen(0);
|
|
793
|
+
try {
|
|
794
|
+
const server = adapter.getNativeServer!() as Server;
|
|
795
|
+
const addr = server.address() as { port: number };
|
|
796
|
+
const res = await fetchJson(addr.port, "/users?limit=10&page=1");
|
|
797
|
+
expect(res.status).toBe(200);
|
|
798
|
+
expect((res.body as Record<string, unknown>).users).toEqual([]);
|
|
799
|
+
} finally {
|
|
800
|
+
await handle.close();
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it("multiple validator failures are aggregated into a single 400 response", async () => {
|
|
805
|
+
const adapter = nativeServer();
|
|
806
|
+
adapter.registerRoutes(
|
|
807
|
+
makeValidatedRouteTable(),
|
|
808
|
+
makeHandlerMap(),
|
|
809
|
+
emptyMiddleware,
|
|
810
|
+
makeValidatorMap(),
|
|
811
|
+
);
|
|
812
|
+
const handle = await adapter.listen(0);
|
|
813
|
+
try {
|
|
814
|
+
const server = adapter.getNativeServer!() as Server;
|
|
815
|
+
const addr = server.address() as { port: number };
|
|
816
|
+
// PUT /users/abc with invalid body — both params and body fail
|
|
817
|
+
const res = await fetchJson(addr.port, "/users/abc", {
|
|
818
|
+
method: "PUT",
|
|
819
|
+
body: { name: 123, email: "bad" },
|
|
820
|
+
});
|
|
821
|
+
expect(res.status).toBe(400);
|
|
822
|
+
const body = res.body as ErrorResponse;
|
|
823
|
+
const fields = body.error.details?.fields as Array<{ path: string }>;
|
|
824
|
+
// Should have params.id error and body field errors
|
|
825
|
+
expect(fields.some((f) => f.path === "params.id")).toBe(true);
|
|
826
|
+
expect(fields.some((f) => f.path.startsWith("body."))).toBe(true);
|
|
827
|
+
expect(fields.length).toBeGreaterThanOrEqual(2);
|
|
828
|
+
} finally {
|
|
829
|
+
await handle.close();
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it("routes without validators still work normally", async () => {
|
|
834
|
+
const adapter = nativeServer();
|
|
835
|
+
adapter.registerRoutes(
|
|
836
|
+
makeValidatedRouteTable(),
|
|
837
|
+
makeHandlerMap(),
|
|
838
|
+
emptyMiddleware,
|
|
839
|
+
makeValidatorMap(),
|
|
840
|
+
);
|
|
841
|
+
const handle = await adapter.listen(0);
|
|
842
|
+
try {
|
|
843
|
+
const server = adapter.getNativeServer!() as Server;
|
|
844
|
+
const addr = server.address() as { port: number };
|
|
845
|
+
// DELETE is not in the validated route table — use original table
|
|
846
|
+
// Use GET /users with valid query to confirm validators work with no issues
|
|
847
|
+
const res = await fetchJson(addr.port, "/users?page=1");
|
|
848
|
+
expect(res.status).toBe(200);
|
|
849
|
+
} finally {
|
|
850
|
+
await handle.close();
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
it("works without validatorMap (backwards compatible)", async () => {
|
|
855
|
+
const adapter = nativeServer();
|
|
856
|
+
// Register without validatorMap — no validators run
|
|
857
|
+
adapter.registerRoutes(makeRouteTable(), makeHandlerMap(), emptyMiddleware);
|
|
858
|
+
const handle = await adapter.listen(0);
|
|
859
|
+
try {
|
|
860
|
+
const server = adapter.getNativeServer!() as Server;
|
|
861
|
+
const addr = server.address() as { port: number };
|
|
862
|
+
const res = await fetchJson(addr.port, "/users");
|
|
863
|
+
expect(res.status).toBe(200);
|
|
864
|
+
} finally {
|
|
865
|
+
await handle.close();
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
// ─── Response Serialization Tests ────────────────────────────
|
|
871
|
+
|
|
872
|
+
/** Route table with serializer references */
|
|
873
|
+
function makeSerializedRouteTable(): CompiledRouteTable {
|
|
874
|
+
const root: CompiledRoute = {
|
|
875
|
+
segment: "",
|
|
876
|
+
handlers: {
|
|
877
|
+
GET: { ref: "root#index", middleware: [], serializer: "RootResponse" },
|
|
878
|
+
},
|
|
879
|
+
children: {
|
|
880
|
+
users: {
|
|
881
|
+
segment: "users",
|
|
882
|
+
handlers: {
|
|
883
|
+
GET: {
|
|
884
|
+
ref: "users#list",
|
|
885
|
+
middleware: [],
|
|
886
|
+
serializer: "UserListResponse",
|
|
887
|
+
},
|
|
888
|
+
POST: { ref: "users#create", middleware: [] }, // no serializer — fallback
|
|
889
|
+
},
|
|
890
|
+
paramChild: {
|
|
891
|
+
segment: ":id",
|
|
892
|
+
paramName: "id",
|
|
893
|
+
handlers: {
|
|
894
|
+
GET: {
|
|
895
|
+
ref: "users#get",
|
|
896
|
+
middleware: [],
|
|
897
|
+
serializer: "UserResponse",
|
|
898
|
+
},
|
|
899
|
+
DELETE: { ref: "users#delete", middleware: [] },
|
|
900
|
+
},
|
|
901
|
+
},
|
|
902
|
+
},
|
|
903
|
+
nested: {
|
|
904
|
+
segment: "nested",
|
|
905
|
+
handlers: {
|
|
906
|
+
GET: {
|
|
907
|
+
ref: "nested#get",
|
|
908
|
+
middleware: [],
|
|
909
|
+
serializer: "NestedResponse",
|
|
910
|
+
},
|
|
911
|
+
},
|
|
912
|
+
},
|
|
913
|
+
types: {
|
|
914
|
+
segment: "types",
|
|
915
|
+
handlers: {
|
|
916
|
+
GET: {
|
|
917
|
+
ref: "types#all",
|
|
918
|
+
middleware: [],
|
|
919
|
+
serializer: "AllTypesResponse",
|
|
920
|
+
},
|
|
921
|
+
},
|
|
922
|
+
},
|
|
923
|
+
"no-schema": {
|
|
924
|
+
segment: "no-schema",
|
|
925
|
+
handlers: {
|
|
926
|
+
GET: {
|
|
927
|
+
ref: "noschema#get",
|
|
928
|
+
middleware: [],
|
|
929
|
+
serializer: "MissingSerializer",
|
|
930
|
+
},
|
|
931
|
+
},
|
|
932
|
+
},
|
|
933
|
+
},
|
|
934
|
+
};
|
|
935
|
+
return root;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function makeSerializerHandlerMap(): HandlerMap {
|
|
939
|
+
return {
|
|
940
|
+
"root#index": async () => ({
|
|
941
|
+
status: 200,
|
|
942
|
+
headers: {},
|
|
943
|
+
body: { message: "Welcome" },
|
|
944
|
+
}),
|
|
945
|
+
"users#list": async () => ({
|
|
946
|
+
status: 200,
|
|
947
|
+
headers: {},
|
|
948
|
+
body: {
|
|
949
|
+
users: [
|
|
950
|
+
{ id: "1", name: "Alice" },
|
|
951
|
+
{ id: "2", name: "Bob" },
|
|
952
|
+
],
|
|
953
|
+
},
|
|
954
|
+
}),
|
|
955
|
+
"users#create": async (req: TypoKitRequest) => ({
|
|
956
|
+
status: 201,
|
|
957
|
+
headers: {},
|
|
958
|
+
body: { created: true, data: req.body },
|
|
959
|
+
}),
|
|
960
|
+
"users#get": async (req: TypoKitRequest) => ({
|
|
961
|
+
status: 200,
|
|
962
|
+
headers: {},
|
|
963
|
+
body: { id: req.params.id, name: "User " + req.params.id },
|
|
964
|
+
}),
|
|
965
|
+
"users#delete": async () => ({
|
|
966
|
+
status: 204,
|
|
967
|
+
headers: {},
|
|
968
|
+
body: null,
|
|
969
|
+
}),
|
|
970
|
+
"nested#get": async () => ({
|
|
971
|
+
status: 200,
|
|
972
|
+
headers: {},
|
|
973
|
+
body: {
|
|
974
|
+
data: { items: [{ a: 1, b: [true, false] }], meta: { total: 1 } },
|
|
975
|
+
},
|
|
976
|
+
}),
|
|
977
|
+
"types#all": async () => ({
|
|
978
|
+
status: 200,
|
|
979
|
+
headers: {},
|
|
980
|
+
body: {
|
|
981
|
+
str: "hello",
|
|
982
|
+
num: 42,
|
|
983
|
+
bool: true,
|
|
984
|
+
nil: null,
|
|
985
|
+
arr: [1, 2, 3],
|
|
986
|
+
obj: { k: "v" },
|
|
987
|
+
},
|
|
988
|
+
}),
|
|
989
|
+
"noschema#get": async () => ({
|
|
990
|
+
status: 200,
|
|
991
|
+
headers: {},
|
|
992
|
+
body: { fallback: true },
|
|
993
|
+
}),
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function makeSerializerMap(): SerializerMap {
|
|
998
|
+
// Simulates compiled fast-json-stringify schemas — produces JSON strings
|
|
999
|
+
return {
|
|
1000
|
+
RootResponse: (input) => JSON.stringify(input),
|
|
1001
|
+
UserListResponse: (input) => {
|
|
1002
|
+
// Custom serializer that produces equivalent JSON but proves it was called
|
|
1003
|
+
const obj = input as Record<string, unknown>;
|
|
1004
|
+
const users = obj.users as Array<Record<string, string>>;
|
|
1005
|
+
return `{"users":[${users.map((u) => `{"id":"${u.id}","name":"${u.name}"}`).join(",")}]}`;
|
|
1006
|
+
},
|
|
1007
|
+
UserResponse: (input) => {
|
|
1008
|
+
const obj = input as Record<string, unknown>;
|
|
1009
|
+
return `{"id":"${obj.id}","name":"${obj.name}"}`;
|
|
1010
|
+
},
|
|
1011
|
+
NestedResponse: (input) => JSON.stringify(input),
|
|
1012
|
+
AllTypesResponse: (input) => JSON.stringify(input),
|
|
1013
|
+
// MissingSerializer is deliberately NOT here to test fallback
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
describe("serializeResponse (unit)", () => {
|
|
1018
|
+
it("returns response unchanged for null body", () => {
|
|
1019
|
+
const res = serializeResponse(
|
|
1020
|
+
{ status: 204, headers: {}, body: null },
|
|
1021
|
+
"Ref",
|
|
1022
|
+
null,
|
|
1023
|
+
);
|
|
1024
|
+
expect(res.body).toBeNull();
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
it("returns response unchanged for undefined body", () => {
|
|
1028
|
+
const res = serializeResponse(
|
|
1029
|
+
{ status: 204, headers: {}, body: undefined },
|
|
1030
|
+
"Ref",
|
|
1031
|
+
null,
|
|
1032
|
+
);
|
|
1033
|
+
expect(res.body).toBeUndefined();
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
it("returns response unchanged for string body", () => {
|
|
1037
|
+
const res = serializeResponse(
|
|
1038
|
+
{ status: 200, headers: {}, body: "plain text" },
|
|
1039
|
+
"Ref",
|
|
1040
|
+
null,
|
|
1041
|
+
);
|
|
1042
|
+
expect(res.body).toBe("plain text");
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
it("uses compiled serializer when available", () => {
|
|
1046
|
+
const serializers: SerializerMap = {
|
|
1047
|
+
TestRef: () => '{"fast":true}',
|
|
1048
|
+
};
|
|
1049
|
+
const res = serializeResponse(
|
|
1050
|
+
{ status: 200, headers: {}, body: { fast: true } },
|
|
1051
|
+
"TestRef",
|
|
1052
|
+
serializers,
|
|
1053
|
+
);
|
|
1054
|
+
expect(res.body).toBe('{"fast":true}');
|
|
1055
|
+
expect(res.headers["content-type"]).toBe("application/json");
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
it("falls back to JSON.stringify when no serializer ref", () => {
|
|
1059
|
+
const res = serializeResponse(
|
|
1060
|
+
{ status: 200, headers: {}, body: { a: 1 } },
|
|
1061
|
+
undefined,
|
|
1062
|
+
null,
|
|
1063
|
+
);
|
|
1064
|
+
expect(res.body).toBe('{"a":1}');
|
|
1065
|
+
expect(res.headers["content-type"]).toBe("application/json");
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
it("falls back to JSON.stringify when serializer ref not in map", () => {
|
|
1069
|
+
const res = serializeResponse(
|
|
1070
|
+
{ status: 200, headers: {}, body: { b: 2 } },
|
|
1071
|
+
"Missing",
|
|
1072
|
+
{},
|
|
1073
|
+
);
|
|
1074
|
+
expect(res.body).toBe('{"b":2}');
|
|
1075
|
+
expect(res.headers["content-type"]).toBe("application/json");
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
it("does not overwrite existing content-type header", () => {
|
|
1079
|
+
const res = serializeResponse(
|
|
1080
|
+
{
|
|
1081
|
+
status: 200,
|
|
1082
|
+
headers: { "content-type": "application/vnd.api+json" },
|
|
1083
|
+
body: { x: 1 },
|
|
1084
|
+
},
|
|
1085
|
+
undefined,
|
|
1086
|
+
null,
|
|
1087
|
+
);
|
|
1088
|
+
expect(res.headers["content-type"]).toBe("application/vnd.api+json");
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it("serializes all JSON types correctly", () => {
|
|
1092
|
+
const body = {
|
|
1093
|
+
str: "hello",
|
|
1094
|
+
num: 42,
|
|
1095
|
+
bool: true,
|
|
1096
|
+
nil: null,
|
|
1097
|
+
arr: [1, 2],
|
|
1098
|
+
obj: { k: "v" },
|
|
1099
|
+
};
|
|
1100
|
+
const res = serializeResponse(
|
|
1101
|
+
{ status: 200, headers: {}, body },
|
|
1102
|
+
undefined,
|
|
1103
|
+
null,
|
|
1104
|
+
);
|
|
1105
|
+
const parsed = JSON.parse(res.body as string);
|
|
1106
|
+
expect(parsed.str).toBe("hello");
|
|
1107
|
+
expect(parsed.num).toBe(42);
|
|
1108
|
+
expect(parsed.bool).toBe(true);
|
|
1109
|
+
expect(parsed.nil).toBeNull();
|
|
1110
|
+
expect(parsed.arr).toEqual([1, 2]);
|
|
1111
|
+
expect(parsed.obj).toEqual({ k: "v" });
|
|
1112
|
+
});
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
describe("response serialization integration", () => {
|
|
1116
|
+
it("serializes response body using compiled serializer", async () => {
|
|
1117
|
+
const adapter = nativeServer();
|
|
1118
|
+
adapter.registerRoutes(
|
|
1119
|
+
makeSerializedRouteTable(),
|
|
1120
|
+
makeSerializerHandlerMap(),
|
|
1121
|
+
emptyMiddleware,
|
|
1122
|
+
undefined,
|
|
1123
|
+
makeSerializerMap(),
|
|
1124
|
+
);
|
|
1125
|
+
const handle = await adapter.listen(0);
|
|
1126
|
+
try {
|
|
1127
|
+
const server = adapter.getNativeServer!() as Server;
|
|
1128
|
+
const addr = server.address() as { port: number };
|
|
1129
|
+
const res = await fetchJson(addr.port, "/users");
|
|
1130
|
+
expect(res.status).toBe(200);
|
|
1131
|
+
const b = res.body as Record<string, unknown>;
|
|
1132
|
+
expect(b.users).toEqual([
|
|
1133
|
+
{ id: "1", name: "Alice" },
|
|
1134
|
+
{ id: "2", name: "Bob" },
|
|
1135
|
+
]);
|
|
1136
|
+
} finally {
|
|
1137
|
+
await handle.close();
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
it("sets content-type to application/json automatically", async () => {
|
|
1142
|
+
const adapter = nativeServer();
|
|
1143
|
+
adapter.registerRoutes(
|
|
1144
|
+
makeSerializedRouteTable(),
|
|
1145
|
+
makeSerializerHandlerMap(),
|
|
1146
|
+
emptyMiddleware,
|
|
1147
|
+
undefined,
|
|
1148
|
+
makeSerializerMap(),
|
|
1149
|
+
);
|
|
1150
|
+
const handle = await adapter.listen(0);
|
|
1151
|
+
try {
|
|
1152
|
+
const server = adapter.getNativeServer!() as Server;
|
|
1153
|
+
const addr = server.address() as { port: number };
|
|
1154
|
+
const res = await fetchJson(addr.port, "/");
|
|
1155
|
+
expect(res.status).toBe(200);
|
|
1156
|
+
expect(res.headers["content-type"]).toContain("application/json");
|
|
1157
|
+
} finally {
|
|
1158
|
+
await handle.close();
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
it("falls back to JSON.stringify when no compiled schema exists", async () => {
|
|
1163
|
+
const adapter = nativeServer();
|
|
1164
|
+
adapter.registerRoutes(
|
|
1165
|
+
makeSerializedRouteTable(),
|
|
1166
|
+
makeSerializerHandlerMap(),
|
|
1167
|
+
emptyMiddleware,
|
|
1168
|
+
undefined,
|
|
1169
|
+
makeSerializerMap(),
|
|
1170
|
+
);
|
|
1171
|
+
const handle = await adapter.listen(0);
|
|
1172
|
+
try {
|
|
1173
|
+
const server = adapter.getNativeServer!() as Server;
|
|
1174
|
+
const addr = server.address() as { port: number };
|
|
1175
|
+
// POST /users has no serializer ref — uses fallback
|
|
1176
|
+
const res = await fetchJson(addr.port, "/users", {
|
|
1177
|
+
method: "POST",
|
|
1178
|
+
body: { name: "Test" },
|
|
1179
|
+
});
|
|
1180
|
+
expect(res.status).toBe(201);
|
|
1181
|
+
const b = res.body as Record<string, unknown>;
|
|
1182
|
+
expect(b.created).toBe(true);
|
|
1183
|
+
expect(res.headers["content-type"]).toContain("application/json");
|
|
1184
|
+
} finally {
|
|
1185
|
+
await handle.close();
|
|
1186
|
+
}
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
it("falls back when serializer ref points to missing serializer in map", async () => {
|
|
1190
|
+
const adapter = nativeServer();
|
|
1191
|
+
adapter.registerRoutes(
|
|
1192
|
+
makeSerializedRouteTable(),
|
|
1193
|
+
makeSerializerHandlerMap(),
|
|
1194
|
+
emptyMiddleware,
|
|
1195
|
+
undefined,
|
|
1196
|
+
makeSerializerMap(),
|
|
1197
|
+
);
|
|
1198
|
+
const handle = await adapter.listen(0);
|
|
1199
|
+
try {
|
|
1200
|
+
const server = adapter.getNativeServer!() as Server;
|
|
1201
|
+
const addr = server.address() as { port: number };
|
|
1202
|
+
// GET /no-schema has serializer: "MissingSerializer" which is not in the map
|
|
1203
|
+
const res = await fetchJson(addr.port, "/no-schema");
|
|
1204
|
+
expect(res.status).toBe(200);
|
|
1205
|
+
const b = res.body as Record<string, unknown>;
|
|
1206
|
+
expect(b.fallback).toBe(true);
|
|
1207
|
+
expect(res.headers["content-type"]).toContain("application/json");
|
|
1208
|
+
} finally {
|
|
1209
|
+
await handle.close();
|
|
1210
|
+
}
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
it("handles nested objects correctly with serializer", async () => {
|
|
1214
|
+
const adapter = nativeServer();
|
|
1215
|
+
adapter.registerRoutes(
|
|
1216
|
+
makeSerializedRouteTable(),
|
|
1217
|
+
makeSerializerHandlerMap(),
|
|
1218
|
+
emptyMiddleware,
|
|
1219
|
+
undefined,
|
|
1220
|
+
makeSerializerMap(),
|
|
1221
|
+
);
|
|
1222
|
+
const handle = await adapter.listen(0);
|
|
1223
|
+
try {
|
|
1224
|
+
const server = adapter.getNativeServer!() as Server;
|
|
1225
|
+
const addr = server.address() as { port: number };
|
|
1226
|
+
const res = await fetchJson(addr.port, "/nested");
|
|
1227
|
+
expect(res.status).toBe(200);
|
|
1228
|
+
const b = res.body as Record<string, unknown>;
|
|
1229
|
+
const data = b.data as Record<string, unknown>;
|
|
1230
|
+
expect(data.items).toEqual([{ a: 1, b: [true, false] }]);
|
|
1231
|
+
expect(data.meta).toEqual({ total: 1 });
|
|
1232
|
+
} finally {
|
|
1233
|
+
await handle.close();
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
it("handles all JSON types (strings, numbers, booleans, nulls, arrays, objects)", async () => {
|
|
1238
|
+
const adapter = nativeServer();
|
|
1239
|
+
adapter.registerRoutes(
|
|
1240
|
+
makeSerializedRouteTable(),
|
|
1241
|
+
makeSerializerHandlerMap(),
|
|
1242
|
+
emptyMiddleware,
|
|
1243
|
+
undefined,
|
|
1244
|
+
makeSerializerMap(),
|
|
1245
|
+
);
|
|
1246
|
+
const handle = await adapter.listen(0);
|
|
1247
|
+
try {
|
|
1248
|
+
const server = adapter.getNativeServer!() as Server;
|
|
1249
|
+
const addr = server.address() as { port: number };
|
|
1250
|
+
const res = await fetchJson(addr.port, "/types");
|
|
1251
|
+
expect(res.status).toBe(200);
|
|
1252
|
+
const b = res.body as Record<string, unknown>;
|
|
1253
|
+
expect(b.str).toBe("hello");
|
|
1254
|
+
expect(b.num).toBe(42);
|
|
1255
|
+
expect(b.bool).toBe(true);
|
|
1256
|
+
expect(b.nil).toBeNull();
|
|
1257
|
+
expect(b.arr).toEqual([1, 2, 3]);
|
|
1258
|
+
expect(b.obj).toEqual({ k: "v" });
|
|
1259
|
+
} finally {
|
|
1260
|
+
await handle.close();
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
it("does not serialize null body (e.g., 204 responses)", async () => {
|
|
1265
|
+
const adapter = nativeServer();
|
|
1266
|
+
adapter.registerRoutes(
|
|
1267
|
+
makeSerializedRouteTable(),
|
|
1268
|
+
makeSerializerHandlerMap(),
|
|
1269
|
+
emptyMiddleware,
|
|
1270
|
+
undefined,
|
|
1271
|
+
makeSerializerMap(),
|
|
1272
|
+
);
|
|
1273
|
+
const handle = await adapter.listen(0);
|
|
1274
|
+
try {
|
|
1275
|
+
const server = adapter.getNativeServer!() as Server;
|
|
1276
|
+
const addr = server.address() as { port: number };
|
|
1277
|
+
const res = await fetchJson(addr.port, "/users/5", { method: "DELETE" });
|
|
1278
|
+
expect(res.status).toBe(204);
|
|
1279
|
+
} finally {
|
|
1280
|
+
await handle.close();
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
it("works without serializerMap (backwards compatible)", async () => {
|
|
1285
|
+
const adapter = nativeServer();
|
|
1286
|
+
adapter.registerRoutes(
|
|
1287
|
+
makeSerializedRouteTable(),
|
|
1288
|
+
makeSerializerHandlerMap(),
|
|
1289
|
+
emptyMiddleware,
|
|
1290
|
+
);
|
|
1291
|
+
const handle = await adapter.listen(0);
|
|
1292
|
+
try {
|
|
1293
|
+
const server = adapter.getNativeServer!() as Server;
|
|
1294
|
+
const addr = server.address() as { port: number };
|
|
1295
|
+
const res = await fetchJson(addr.port, "/");
|
|
1296
|
+
expect(res.status).toBe(200);
|
|
1297
|
+
expect((res.body as Record<string, unknown>).message).toBe("Welcome");
|
|
1298
|
+
expect(res.headers["content-type"]).toContain("application/json");
|
|
1299
|
+
} finally {
|
|
1300
|
+
await handle.close();
|
|
1301
|
+
}
|
|
1302
|
+
});
|
|
1303
|
+
});
|