@prosopo/api-express-router 3.2.0 → 3.2.2
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/.turbo/turbo-build$colon$cjs.log +3 -6
- package/.turbo/turbo-build$colon$tsc.log +21 -24
- package/.turbo/turbo-build.log +3 -9
- package/CHANGELOG.md +30 -0
- package/LICENSE +201 -0
- package/package.json +62 -59
- package/src/apiExpressRouterFactory.ts +0 -68
- package/src/endpointAdapter/apiExpressDefaultEndpointAdapter.ts +0 -92
- package/src/endpointAdapter/apiExpressEndpointAdapter.ts +0 -28
- package/src/errorHandler.ts +0 -33
- package/src/index.ts +0 -38
- package/src/middlewares/authMiddleware.ts +0 -150
- package/src/middlewares/jwtReplayGuard.ts +0 -56
- package/src/middlewares/requestLoggerMiddleware.ts +0 -104
- package/src/tests/apiExpressRouter.test-d.ts +0 -196
- package/src/tests/unit/apiExpressDefaultEndpointAdapter.unit.test.ts +0 -414
- package/src/tests/unit/apiExpressRouterFactory.unit.test.ts +0 -215
- package/src/tests/unit/errorHandler.unit.test.ts +0 -190
- package/src/tests/unit/index.unit.test.ts +0 -95
- package/src/tests/unit/middlewares/authMiddleware.unit.test.ts +0 -449
- package/src/tests/unit/middlewares/jwtReplayGuard.unit.test.ts +0 -53
- package/src/tests/unit/middlewares/requestLoggerMiddleware.unit.test.ts +0 -368
- package/src/tests/unit/testDoubles.ts +0 -39
- package/tsconfig.cjs.json +0 -37
- package/tsconfig.json +0 -44
- package/tsconfig.tsbuildinfo +0 -1
- package/tsconfig.types.json +0 -9
|
@@ -1,414 +0,0 @@
|
|
|
1
|
-
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
-
//
|
|
3
|
-
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
-
// you may not use this file except in compliance with the License.
|
|
5
|
-
// You may obtain a copy of the License at
|
|
6
|
-
//
|
|
7
|
-
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
-
//
|
|
9
|
-
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
-
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
-
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
-
// See the License for the specific language governing permissions and
|
|
13
|
-
// limitations under the License.
|
|
14
|
-
|
|
15
|
-
import {
|
|
16
|
-
type ApiEndpoint,
|
|
17
|
-
type ApiEndpointResponse,
|
|
18
|
-
ApiEndpointResponseStatus,
|
|
19
|
-
} from "@prosopo/api-route";
|
|
20
|
-
import { ProsopoApiError } from "@prosopo/common";
|
|
21
|
-
import { LogLevel, type Logger } from "@prosopo/logger";
|
|
22
|
-
import type { Request, Response } from "express";
|
|
23
|
-
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
24
|
-
import { type ZodType, z } from "zod";
|
|
25
|
-
import { ApiExpressDefaultEndpointAdapter } from "../../endpointAdapter/apiExpressDefaultEndpointAdapter.js";
|
|
26
|
-
import { type NextCapture, captureNext } from "./testDoubles.js";
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* The adapter is the only place request bodies are validated and the only place
|
|
30
|
-
* an endpoint's throw is turned into a response, so the split matters: a bad
|
|
31
|
-
* body must reach the error handler through next(), while a failure inside the
|
|
32
|
-
* endpoint must be answered directly with a 500.
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
interface Harness {
|
|
36
|
-
adapter: ApiExpressDefaultEndpointAdapter;
|
|
37
|
-
request: Request;
|
|
38
|
-
response: Response;
|
|
39
|
-
next: NextCapture;
|
|
40
|
-
logger: Logger;
|
|
41
|
-
json: ReturnType<typeof vi.fn<(body: unknown) => Response>>;
|
|
42
|
-
status: ReturnType<typeof vi.fn<(code: number) => Response>>;
|
|
43
|
-
send: ReturnType<typeof vi.fn<(body: unknown) => Response>>;
|
|
44
|
-
logged: ReturnType<typeof vi.fn<(entry: () => unknown) => void>>;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const build = (body: unknown = {}, errorStatusCode = 500): Harness => {
|
|
48
|
-
const logged = vi.fn<(entry: () => unknown) => void>();
|
|
49
|
-
const logger = {
|
|
50
|
-
error: logged,
|
|
51
|
-
info: vi.fn<(entry: () => unknown) => void>(),
|
|
52
|
-
debug: vi.fn<(entry: () => unknown) => void>(),
|
|
53
|
-
warn: vi.fn<(entry: () => unknown) => void>(),
|
|
54
|
-
} as unknown as Logger;
|
|
55
|
-
|
|
56
|
-
const json = vi.fn<(payload: unknown) => Response>();
|
|
57
|
-
const send = vi.fn<(payload: unknown) => Response>();
|
|
58
|
-
const status = vi.fn<(code: number) => Response>();
|
|
59
|
-
const response = { json, send, status } as unknown as Response;
|
|
60
|
-
status.mockReturnValue(response);
|
|
61
|
-
|
|
62
|
-
return {
|
|
63
|
-
adapter: new ApiExpressDefaultEndpointAdapter(
|
|
64
|
-
LogLevel.enum.info,
|
|
65
|
-
errorStatusCode,
|
|
66
|
-
),
|
|
67
|
-
request: { body, logger } as unknown as Request,
|
|
68
|
-
response,
|
|
69
|
-
next: captureNext(),
|
|
70
|
-
logger,
|
|
71
|
-
json,
|
|
72
|
-
status,
|
|
73
|
-
send,
|
|
74
|
-
logged,
|
|
75
|
-
};
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
const endpoint = (
|
|
79
|
-
schema: ZodType | undefined,
|
|
80
|
-
process: ApiEndpoint<ZodType | undefined>["processRequest"] = async () => ({
|
|
81
|
-
status: ApiEndpointResponseStatus.SUCCESS,
|
|
82
|
-
}),
|
|
83
|
-
): ApiEndpoint<ZodType | undefined> => ({
|
|
84
|
-
getRequestArgsSchema: () => schema,
|
|
85
|
-
processRequest:
|
|
86
|
-
vi.fn<ApiEndpoint<ZodType | undefined>["processRequest"]>(process),
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
let harness: Harness;
|
|
90
|
-
|
|
91
|
-
beforeEach(() => {
|
|
92
|
-
harness = build();
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
describe("a request the endpoint accepts", () => {
|
|
96
|
-
test("parses the body and passes the parsed value on", async () => {
|
|
97
|
-
harness = build({ count: "5" });
|
|
98
|
-
const schema = z.object({ count: z.coerce.number() });
|
|
99
|
-
const target = endpoint(schema);
|
|
100
|
-
|
|
101
|
-
await harness.adapter.handleRequest(
|
|
102
|
-
target,
|
|
103
|
-
harness.request,
|
|
104
|
-
harness.response,
|
|
105
|
-
harness.next.fn,
|
|
106
|
-
);
|
|
107
|
-
|
|
108
|
-
// The parsed value, not the raw body: coercion has to survive the trip.
|
|
109
|
-
expect(target.processRequest).toHaveBeenCalledWith(
|
|
110
|
-
{ count: 5 },
|
|
111
|
-
harness.logger,
|
|
112
|
-
);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test("passes undefined args when the endpoint declares no schema", async () => {
|
|
116
|
-
const target = endpoint(undefined);
|
|
117
|
-
await harness.adapter.handleRequest(
|
|
118
|
-
target,
|
|
119
|
-
harness.request,
|
|
120
|
-
harness.response,
|
|
121
|
-
harness.next.fn,
|
|
122
|
-
);
|
|
123
|
-
expect(target.processRequest).toHaveBeenCalledWith(
|
|
124
|
-
undefined,
|
|
125
|
-
harness.logger,
|
|
126
|
-
);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
test("hands the endpoint the request's own logger", async () => {
|
|
130
|
-
const target = endpoint(undefined);
|
|
131
|
-
await harness.adapter.handleRequest(
|
|
132
|
-
target,
|
|
133
|
-
harness.request,
|
|
134
|
-
harness.response,
|
|
135
|
-
harness.next.fn,
|
|
136
|
-
);
|
|
137
|
-
expect(vi.mocked(target.processRequest).mock.calls[0]?.[1]).toBe(
|
|
138
|
-
harness.logger,
|
|
139
|
-
);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test("answers with the endpoint's response as JSON", async () => {
|
|
143
|
-
await harness.adapter.handleRequest(
|
|
144
|
-
endpoint(undefined, async () => ({
|
|
145
|
-
status: ApiEndpointResponseStatus.SUCCESS,
|
|
146
|
-
data: { value: 1 },
|
|
147
|
-
})),
|
|
148
|
-
harness.request,
|
|
149
|
-
harness.response,
|
|
150
|
-
harness.next.fn,
|
|
151
|
-
);
|
|
152
|
-
expect(harness.json).toHaveBeenCalledWith({
|
|
153
|
-
status: ApiEndpointResponseStatus.SUCCESS,
|
|
154
|
-
data: { value: 1 },
|
|
155
|
-
});
|
|
156
|
-
expect(harness.next.calls).toHaveLength(0);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test("stringifies bigints, which res.json() cannot serialise", async () => {
|
|
160
|
-
await harness.adapter.handleRequest(
|
|
161
|
-
endpoint(undefined, async () => ({
|
|
162
|
-
status: ApiEndpointResponseStatus.SUCCESS,
|
|
163
|
-
data: { big: 10n },
|
|
164
|
-
})),
|
|
165
|
-
harness.request,
|
|
166
|
-
harness.response,
|
|
167
|
-
harness.next.fn,
|
|
168
|
-
);
|
|
169
|
-
expect(harness.json).toHaveBeenCalledWith({
|
|
170
|
-
status: ApiEndpointResponseStatus.SUCCESS,
|
|
171
|
-
data: { big: "10" },
|
|
172
|
-
});
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
test("passes an empty data payload straight through", async () => {
|
|
176
|
-
// Nothing coerces an empty result into a body, so the client sees
|
|
177
|
-
// exactly what the endpoint returned.
|
|
178
|
-
await harness.adapter.handleRequest(
|
|
179
|
-
endpoint(undefined, async () => ({
|
|
180
|
-
status: ApiEndpointResponseStatus.SUCCESS,
|
|
181
|
-
data: {},
|
|
182
|
-
})),
|
|
183
|
-
harness.request,
|
|
184
|
-
harness.response,
|
|
185
|
-
harness.next.fn,
|
|
186
|
-
);
|
|
187
|
-
expect(harness.json).toHaveBeenCalledWith({
|
|
188
|
-
status: ApiEndpointResponseStatus.SUCCESS,
|
|
189
|
-
data: {},
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test("an absent request body is still handed to the schema", async () => {
|
|
194
|
-
// Express only populates req.body when a body parser is mounted, so an
|
|
195
|
-
// endpoint whose schema tolerates undefined has to keep working.
|
|
196
|
-
harness.request.body = undefined;
|
|
197
|
-
const schema = z.object({ a: z.string() }).optional();
|
|
198
|
-
const target = endpoint(schema);
|
|
199
|
-
await harness.adapter.handleRequest(
|
|
200
|
-
target,
|
|
201
|
-
harness.request,
|
|
202
|
-
harness.response,
|
|
203
|
-
harness.next.fn,
|
|
204
|
-
);
|
|
205
|
-
expect(target.processRequest).toHaveBeenCalledWith(
|
|
206
|
-
undefined,
|
|
207
|
-
harness.logger,
|
|
208
|
-
);
|
|
209
|
-
expect(harness.json).toHaveBeenCalledWith({
|
|
210
|
-
status: ApiEndpointResponseStatus.SUCCESS,
|
|
211
|
-
});
|
|
212
|
-
});
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
describe("a body the schema rejects", () => {
|
|
216
|
-
test("goes to the error handler rather than being answered here", async () => {
|
|
217
|
-
harness = build({ count: "not a number" });
|
|
218
|
-
const target = endpoint(z.object({ count: z.number() }));
|
|
219
|
-
|
|
220
|
-
await harness.adapter.handleRequest(
|
|
221
|
-
target,
|
|
222
|
-
harness.request,
|
|
223
|
-
harness.response,
|
|
224
|
-
harness.next.fn,
|
|
225
|
-
);
|
|
226
|
-
|
|
227
|
-
expect(harness.next.calls).toHaveLength(1);
|
|
228
|
-
expect(harness.json).not.toHaveBeenCalled();
|
|
229
|
-
expect(target.processRequest).not.toHaveBeenCalled();
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
test("is reported as a 400 parse error carrying the cause", async () => {
|
|
233
|
-
harness = build({ count: "not a number" });
|
|
234
|
-
await harness.adapter.handleRequest(
|
|
235
|
-
endpoint(z.object({ count: z.number() })),
|
|
236
|
-
harness.request,
|
|
237
|
-
harness.response,
|
|
238
|
-
harness.next.fn,
|
|
239
|
-
);
|
|
240
|
-
|
|
241
|
-
const [forwarded] = harness.next.calls[0] ?? [];
|
|
242
|
-
expect(forwarded).toBeInstanceOf(ProsopoApiError);
|
|
243
|
-
const error = forwarded as ProsopoApiError;
|
|
244
|
-
expect(error.context?.code).toBe(400);
|
|
245
|
-
expect(error.context?.error).toBeDefined();
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
test("a schema that throws something other than a ZodError is treated the same", async () => {
|
|
249
|
-
// getRequestArgsSchema().parse is only known to throw; nothing depends
|
|
250
|
-
// on it being a ZodError, so a custom schema cannot escape the 400.
|
|
251
|
-
const schema = {
|
|
252
|
-
parse: () => {
|
|
253
|
-
throw new Error("custom validator exploded");
|
|
254
|
-
},
|
|
255
|
-
} as unknown as ZodType;
|
|
256
|
-
|
|
257
|
-
await harness.adapter.handleRequest(
|
|
258
|
-
endpoint(schema),
|
|
259
|
-
harness.request,
|
|
260
|
-
harness.response,
|
|
261
|
-
harness.next.fn,
|
|
262
|
-
);
|
|
263
|
-
|
|
264
|
-
expect(harness.next.calls).toHaveLength(1);
|
|
265
|
-
expect(harness.next.calls[0]?.[0]).toBeInstanceOf(ProsopoApiError);
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
test("an endpoint that fails while building its schema is caught too", async () => {
|
|
269
|
-
// getRequestArgsSchema() is called inside the same try as parse(), so a
|
|
270
|
-
// broken schema factory is reported as a bad request rather than
|
|
271
|
-
// escaping handleRequest as an unhandled rejection.
|
|
272
|
-
const target: ApiEndpoint<ZodType | undefined> = {
|
|
273
|
-
getRequestArgsSchema: () => {
|
|
274
|
-
throw new Error("schema construction failed");
|
|
275
|
-
},
|
|
276
|
-
processRequest:
|
|
277
|
-
vi.fn<ApiEndpoint<ZodType | undefined>["processRequest"]>(),
|
|
278
|
-
};
|
|
279
|
-
|
|
280
|
-
await harness.adapter.handleRequest(
|
|
281
|
-
target,
|
|
282
|
-
harness.request,
|
|
283
|
-
harness.response,
|
|
284
|
-
harness.next.fn,
|
|
285
|
-
);
|
|
286
|
-
|
|
287
|
-
expect(harness.next.calls[0]?.[0]).toBeInstanceOf(ProsopoApiError);
|
|
288
|
-
expect(target.processRequest).not.toHaveBeenCalled();
|
|
289
|
-
});
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
describe("an endpoint that fails", () => {
|
|
293
|
-
test("is answered with the adapter's error status and the json envelope", async () => {
|
|
294
|
-
await harness.adapter.handleRequest(
|
|
295
|
-
endpoint(undefined, async () => {
|
|
296
|
-
throw new Error("database down");
|
|
297
|
-
}),
|
|
298
|
-
harness.request,
|
|
299
|
-
harness.response,
|
|
300
|
-
harness.next.fn,
|
|
301
|
-
);
|
|
302
|
-
|
|
303
|
-
expect(harness.status).toHaveBeenCalledWith(500);
|
|
304
|
-
// Every other route answers `{ error: ... }`; a plain-text body here meant
|
|
305
|
-
// a client parsing the envelope got a parse failure instead of the error.
|
|
306
|
-
expect(harness.json).toHaveBeenCalledWith({
|
|
307
|
-
error: expect.anything(),
|
|
308
|
-
});
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
test("does not leak the failure's message to the client", async () => {
|
|
312
|
-
await harness.adapter.handleRequest(
|
|
313
|
-
endpoint(undefined, async () => {
|
|
314
|
-
throw new Error("connection string: postgres://user:secret@host");
|
|
315
|
-
}),
|
|
316
|
-
harness.request,
|
|
317
|
-
harness.response,
|
|
318
|
-
harness.next.fn,
|
|
319
|
-
);
|
|
320
|
-
expect(JSON.stringify(harness.json.mock.calls[0]?.[0])).not.toContain(
|
|
321
|
-
"secret",
|
|
322
|
-
);
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
test("logs the failure against the request's logger", async () => {
|
|
326
|
-
const failure = new Error("database down");
|
|
327
|
-
await harness.adapter.handleRequest(
|
|
328
|
-
endpoint(undefined, async () => {
|
|
329
|
-
throw failure;
|
|
330
|
-
}),
|
|
331
|
-
harness.request,
|
|
332
|
-
harness.response,
|
|
333
|
-
harness.next.fn,
|
|
334
|
-
);
|
|
335
|
-
|
|
336
|
-
expect(harness.logged).toHaveBeenCalledTimes(1);
|
|
337
|
-
const entry = harness.logged.mock.calls[0]?.[0];
|
|
338
|
-
expect(entry?.()).toEqual({ err: failure });
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
test("does not also call next(), so the error handler is not double-run", async () => {
|
|
342
|
-
await harness.adapter.handleRequest(
|
|
343
|
-
endpoint(undefined, async () => {
|
|
344
|
-
throw new Error("database down");
|
|
345
|
-
}),
|
|
346
|
-
harness.request,
|
|
347
|
-
harness.response,
|
|
348
|
-
harness.next.fn,
|
|
349
|
-
);
|
|
350
|
-
expect(harness.next.calls).toHaveLength(0);
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
test("answers with the configured errorStatusCode", async () => {
|
|
354
|
-
// The admin router builds its adapter with 400, so a client error there
|
|
355
|
-
// used to surface as a 500 in plain text.
|
|
356
|
-
harness = build({}, 503);
|
|
357
|
-
await harness.adapter.handleRequest(
|
|
358
|
-
endpoint(undefined, async () => {
|
|
359
|
-
throw new Error("unavailable");
|
|
360
|
-
}),
|
|
361
|
-
harness.request,
|
|
362
|
-
harness.response,
|
|
363
|
-
harness.next.fn,
|
|
364
|
-
);
|
|
365
|
-
expect(harness.status).toHaveBeenCalledWith(503);
|
|
366
|
-
expect(harness.status).not.toHaveBeenCalledWith(500);
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
test("an error carrying its own status code keeps that code", async () => {
|
|
370
|
-
// A ProsopoApiError already knows what it is; overwriting its code with
|
|
371
|
-
// the adapter's default turned a 400 into a 500.
|
|
372
|
-
await harness.adapter.handleRequest(
|
|
373
|
-
endpoint(undefined, async () => {
|
|
374
|
-
throw new ProsopoApiError("API.BAD_REQUEST", {
|
|
375
|
-
context: { code: 400 },
|
|
376
|
-
});
|
|
377
|
-
}),
|
|
378
|
-
harness.request,
|
|
379
|
-
harness.response,
|
|
380
|
-
harness.next.fn,
|
|
381
|
-
);
|
|
382
|
-
expect(harness.status).toHaveBeenCalledWith(400);
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
test("a rejection with a non-Error value is handled just the same", async () => {
|
|
386
|
-
await harness.adapter.handleRequest(
|
|
387
|
-
endpoint(undefined, async () => {
|
|
388
|
-
throw "a bare string";
|
|
389
|
-
}),
|
|
390
|
-
harness.request,
|
|
391
|
-
harness.response,
|
|
392
|
-
harness.next.fn,
|
|
393
|
-
);
|
|
394
|
-
expect(harness.status).toHaveBeenCalledWith(500);
|
|
395
|
-
expect(harness.logged).toHaveBeenCalledTimes(1);
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
test("a failure while serialising the response is caught too", async () => {
|
|
399
|
-
// stringifyBigInts walks the response, so a getter that throws lands in
|
|
400
|
-
// the same catch as an endpoint failure rather than escaping.
|
|
401
|
-
const hostile: ApiEndpointResponse = {
|
|
402
|
-
get status(): ApiEndpointResponseStatus {
|
|
403
|
-
throw new Error("getter exploded");
|
|
404
|
-
},
|
|
405
|
-
};
|
|
406
|
-
await harness.adapter.handleRequest(
|
|
407
|
-
endpoint(undefined, async () => hostile),
|
|
408
|
-
harness.request,
|
|
409
|
-
harness.response,
|
|
410
|
-
harness.next.fn,
|
|
411
|
-
);
|
|
412
|
-
expect(harness.status).toHaveBeenCalledWith(500);
|
|
413
|
-
});
|
|
414
|
-
});
|
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
-
//
|
|
3
|
-
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
-
// you may not use this file except in compliance with the License.
|
|
5
|
-
// You may obtain a copy of the License at
|
|
6
|
-
//
|
|
7
|
-
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
-
//
|
|
9
|
-
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
-
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
-
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
-
// See the License for the specific language governing permissions and
|
|
13
|
-
// limitations under the License.
|
|
14
|
-
|
|
15
|
-
import {
|
|
16
|
-
type ApiEndpoint,
|
|
17
|
-
ApiEndpointResponseStatus,
|
|
18
|
-
type ApiRoutes,
|
|
19
|
-
type ApiRoutesProvider,
|
|
20
|
-
} from "@prosopo/api-route";
|
|
21
|
-
import type { NextFunction, Request, Response, Router } from "express";
|
|
22
|
-
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
23
|
-
import type { ZodType } from "zod";
|
|
24
|
-
import { ApiExpressRouterFactory } from "../../apiExpressRouterFactory.js";
|
|
25
|
-
import type { ApiExpressEndpointAdapter } from "../../endpointAdapter/apiExpressEndpointAdapter.js";
|
|
26
|
-
import { handleErrors } from "../../errorHandler.js";
|
|
27
|
-
import { captureNext } from "./testDoubles.js";
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* The factory is the wiring between a routes provider and express. What matters
|
|
31
|
-
* is that every declared route reaches the adapter untouched, that the error
|
|
32
|
-
* handler is mounted last, and that a provider handing back nothing still
|
|
33
|
-
* produces a usable router rather than throwing.
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
type Handler = (
|
|
37
|
-
request: Request,
|
|
38
|
-
response: Response,
|
|
39
|
-
next: NextFunction,
|
|
40
|
-
) => Promise<void>;
|
|
41
|
-
|
|
42
|
-
const endpoint = (): ApiEndpoint<ZodType | undefined> => ({
|
|
43
|
-
getRequestArgsSchema: vi.fn<() => ZodType | undefined>(() => undefined),
|
|
44
|
-
processRequest: vi.fn<ApiEndpoint<ZodType | undefined>["processRequest"]>(
|
|
45
|
-
async () => ({ status: ApiEndpointResponseStatus.SUCCESS }),
|
|
46
|
-
),
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
const provider = (routes: ApiRoutes): ApiRoutesProvider => ({
|
|
50
|
-
getRoutes: (): ApiRoutes => routes,
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
const adapter = (): ApiExpressEndpointAdapter => ({
|
|
54
|
-
handleRequest: vi.fn<ApiExpressEndpointAdapter["handleRequest"]>(
|
|
55
|
-
async () => undefined,
|
|
56
|
-
),
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
/** The (path, handler) pairs the factory registered as POST routes. */
|
|
60
|
-
const postRoutes = (router: Router): [string, Handler][] => {
|
|
61
|
-
const registered: [string, Handler][] = [];
|
|
62
|
-
for (const layer of router.stack) {
|
|
63
|
-
const route = layer.route;
|
|
64
|
-
if (!route) continue;
|
|
65
|
-
const handler = route.stack[route.stack.length - 1]?.handle as
|
|
66
|
-
| Handler
|
|
67
|
-
| undefined;
|
|
68
|
-
if (handler) registered.push([route.path, handler]);
|
|
69
|
-
}
|
|
70
|
-
return registered;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
let factory: ApiExpressRouterFactory;
|
|
74
|
-
|
|
75
|
-
beforeEach(() => {
|
|
76
|
-
factory = new ApiExpressRouterFactory();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
describe("createRouter", () => {
|
|
80
|
-
test("registers one POST route per entry the provider declares", () => {
|
|
81
|
-
const router = factory.createRouter(
|
|
82
|
-
provider({ "/a": endpoint(), "/b": endpoint() }),
|
|
83
|
-
adapter(),
|
|
84
|
-
);
|
|
85
|
-
expect(postRoutes(router).map(([path]) => path)).toEqual(["/a", "/b"]);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test("registers the routes as POST only", () => {
|
|
89
|
-
// express records the verbs it accepted on the route itself; anything
|
|
90
|
-
// beyond post here would widen the API surface silently.
|
|
91
|
-
const router = factory.createRouter(
|
|
92
|
-
provider({ "/a": endpoint() }),
|
|
93
|
-
adapter(),
|
|
94
|
-
);
|
|
95
|
-
const route = router.stack.find((layer) => layer.route)?.route as
|
|
96
|
-
| { methods: Record<string, boolean> }
|
|
97
|
-
| undefined;
|
|
98
|
-
expect(route?.methods).toEqual({ post: true });
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test("a provider with no routes still yields a router", () => {
|
|
102
|
-
// An empty route table is a legitimate state during start-up, so it must
|
|
103
|
-
// not throw: the router simply has nothing but the error handler on it.
|
|
104
|
-
const router = factory.createRouter(provider({}), adapter());
|
|
105
|
-
expect(postRoutes(router)).toEqual([]);
|
|
106
|
-
expect(router.stack).toHaveLength(1);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test("mounts the error handler last, after every route", () => {
|
|
110
|
-
// Express only reaches an error handler declared after the handlers that
|
|
111
|
-
// might fail, so its position is load-bearing rather than cosmetic.
|
|
112
|
-
const router = factory.createRouter(
|
|
113
|
-
provider({ "/a": endpoint(), "/b": endpoint() }),
|
|
114
|
-
adapter(),
|
|
115
|
-
);
|
|
116
|
-
const last = router.stack[router.stack.length - 1];
|
|
117
|
-
expect(last?.handle).toBe(handleErrors);
|
|
118
|
-
expect(last?.route).toBeUndefined();
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
test("asks the provider for its routes exactly once", () => {
|
|
122
|
-
const getRoutes = vi.fn<() => ApiRoutes>(() => ({ "/a": endpoint() }));
|
|
123
|
-
factory.createRouter({ getRoutes }, adapter());
|
|
124
|
-
expect(getRoutes).toHaveBeenCalledTimes(1);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test("each call builds a fresh router", () => {
|
|
128
|
-
const routes = provider({ "/a": endpoint() });
|
|
129
|
-
expect(factory.createRouter(routes, adapter())).not.toBe(
|
|
130
|
-
factory.createRouter(routes, adapter()),
|
|
131
|
-
);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test("routes named oddly are passed to express verbatim", () => {
|
|
135
|
-
// The factory does no normalising of its own, so a provider is free to
|
|
136
|
-
// use path parameters or a bare root route.
|
|
137
|
-
const router = factory.createRouter(
|
|
138
|
-
provider({ "/": endpoint(), "/nested/:id": endpoint() }),
|
|
139
|
-
adapter(),
|
|
140
|
-
);
|
|
141
|
-
expect(postRoutes(router).map(([path]) => path)).toEqual([
|
|
142
|
-
"/",
|
|
143
|
-
"/nested/:id",
|
|
144
|
-
]);
|
|
145
|
-
});
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
describe("the handler each route is given", () => {
|
|
149
|
-
test("hands the endpoint and the express trio to the adapter", async () => {
|
|
150
|
-
const target = endpoint();
|
|
151
|
-
const endpointAdapter = adapter();
|
|
152
|
-
const router = factory.createRouter(
|
|
153
|
-
provider({ "/a": target }),
|
|
154
|
-
endpointAdapter,
|
|
155
|
-
);
|
|
156
|
-
const request = {} as Request;
|
|
157
|
-
const response = {} as Response;
|
|
158
|
-
const next = captureNext();
|
|
159
|
-
|
|
160
|
-
const handler = postRoutes(router)[0]?.[1];
|
|
161
|
-
await handler?.(request, response, next.fn);
|
|
162
|
-
|
|
163
|
-
expect(endpointAdapter.handleRequest).toHaveBeenCalledWith(
|
|
164
|
-
target,
|
|
165
|
-
request,
|
|
166
|
-
response,
|
|
167
|
-
next.fn,
|
|
168
|
-
);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test("routes each path to its own endpoint, not the last one registered", () => {
|
|
172
|
-
// A loop that closes over the wrong variable would send every request to
|
|
173
|
-
// whichever endpoint happened to be last.
|
|
174
|
-
const first = endpoint();
|
|
175
|
-
const second = endpoint();
|
|
176
|
-
const endpointAdapter = adapter();
|
|
177
|
-
const router = factory.createRouter(
|
|
178
|
-
provider({ "/first": first, "/second": second }),
|
|
179
|
-
endpointAdapter,
|
|
180
|
-
);
|
|
181
|
-
|
|
182
|
-
for (const [, handler] of postRoutes(router)) {
|
|
183
|
-
void handler({} as Request, {} as Response, captureNext().fn);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const handled = vi.mocked(endpointAdapter.handleRequest).mock.calls;
|
|
187
|
-
expect(handled.map(([called]) => called)).toEqual([first, second]);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test("propagates a rejection from the adapter to the caller", async () => {
|
|
191
|
-
// The handler returns the adapter's promise, so express receives the
|
|
192
|
-
// rejection rather than it becoming an unhandled one.
|
|
193
|
-
const endpointAdapter = adapter();
|
|
194
|
-
vi.mocked(endpointAdapter.handleRequest).mockRejectedValue(
|
|
195
|
-
new Error("adapter blew up"),
|
|
196
|
-
);
|
|
197
|
-
const router = factory.createRouter(
|
|
198
|
-
provider({ "/a": endpoint() }),
|
|
199
|
-
endpointAdapter,
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
const handler = postRoutes(router)[0]?.[1];
|
|
203
|
-
await expect(
|
|
204
|
-
handler?.({} as Request, {} as Response, captureNext().fn),
|
|
205
|
-
).rejects.toThrow("adapter blew up");
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
test("does not call the endpoint itself — that is the adapter's job", async () => {
|
|
209
|
-
const target = endpoint();
|
|
210
|
-
const router = factory.createRouter(provider({ "/a": target }), adapter());
|
|
211
|
-
const handler = postRoutes(router)[0]?.[1];
|
|
212
|
-
await handler?.({} as Request, {} as Response, captureNext().fn);
|
|
213
|
-
expect(target.processRequest).not.toHaveBeenCalled();
|
|
214
|
-
});
|
|
215
|
-
});
|