@schmock/core 1.9.0 → 1.9.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/dist/builder.d.ts +0 -40
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +65 -217
- package/dist/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +7 -0
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -0
- package/dist/http-helpers.d.ts +4 -1
- package/dist/http-helpers.d.ts.map +1 -1
- package/dist/http-helpers.js +18 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/plugin-pipeline.d.ts +15 -0
- package/dist/plugin-pipeline.d.ts.map +1 -0
- package/dist/plugin-pipeline.js +66 -0
- package/dist/response-parser.d.ts +6 -0
- package/dist/response-parser.d.ts.map +1 -0
- package/dist/response-parser.js +57 -0
- package/dist/route-matcher.d.ts +24 -0
- package/dist/route-matcher.d.ts.map +1 -0
- package/dist/route-matcher.js +39 -0
- package/package.json +3 -2
- package/src/builder.ts +83 -314
- package/src/constants.ts +9 -0
- package/src/errors.ts +4 -0
- package/src/http-helpers.ts +24 -2
- package/src/index.ts +1 -0
- package/src/plugin-pipeline.ts +100 -0
- package/src/response-parser.ts +74 -0
- package/src/route-matcher.ts +69 -0
package/src/builder.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Server } from "node:http";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
-
import {
|
|
3
|
+
import { normalizePath, toHttpMethod } from "./constants.js";
|
|
4
4
|
import {
|
|
5
|
-
|
|
5
|
+
errorMessage,
|
|
6
6
|
RouteDefinitionError,
|
|
7
7
|
RouteNotFoundError,
|
|
8
8
|
SchmockError,
|
|
@@ -14,10 +14,14 @@ import {
|
|
|
14
14
|
writeSchmockResponse,
|
|
15
15
|
} from "./http-helpers.js";
|
|
16
16
|
import { parseRouteKey } from "./parser.js";
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
import { runPluginPipeline } from "./plugin-pipeline.js";
|
|
18
|
+
import { parseResponse } from "./response-parser.js";
|
|
19
|
+
import type { CompiledCallableRoute } from "./route-matcher.js";
|
|
20
|
+
import {
|
|
21
|
+
extractParams,
|
|
22
|
+
findRoute,
|
|
23
|
+
isGeneratorFunction,
|
|
24
|
+
} from "./route-matcher.js";
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
27
|
* Debug logger that respects debug mode configuration
|
|
@@ -49,37 +53,6 @@ class DebugLogger {
|
|
|
49
53
|
}
|
|
50
54
|
}
|
|
51
55
|
|
|
52
|
-
/**
|
|
53
|
-
* Compiled callable route with pattern matching
|
|
54
|
-
*/
|
|
55
|
-
interface CompiledCallableRoute {
|
|
56
|
-
pattern: RegExp;
|
|
57
|
-
params: string[];
|
|
58
|
-
method: Schmock.HttpMethod;
|
|
59
|
-
path: string;
|
|
60
|
-
generator: Schmock.Generator;
|
|
61
|
-
config: Schmock.RouteConfig;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function isGeneratorFunction(
|
|
65
|
-
gen: Schmock.Generator,
|
|
66
|
-
): gen is Schmock.GeneratorFunction {
|
|
67
|
-
return typeof gen === "function";
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function isResponseObject(value: unknown): value is {
|
|
71
|
-
status: number;
|
|
72
|
-
body: unknown;
|
|
73
|
-
headers?: Record<string, string>;
|
|
74
|
-
} {
|
|
75
|
-
return (
|
|
76
|
-
typeof value === "object" &&
|
|
77
|
-
value !== null &&
|
|
78
|
-
"status" in value &&
|
|
79
|
-
"body" in value
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
56
|
/**
|
|
84
57
|
* Callable mock instance that implements the new API.
|
|
85
58
|
*
|
|
@@ -180,11 +153,7 @@ export class CallableMockInstance {
|
|
|
180
153
|
// Store static routes (no params) in Map for O(1) lookup
|
|
181
154
|
// Only store the first registration — "first registration wins" semantics
|
|
182
155
|
if (parsed.params.length === 0) {
|
|
183
|
-
const
|
|
184
|
-
parsed.path.endsWith("/") && parsed.path !== "/"
|
|
185
|
-
? parsed.path.slice(0, -1)
|
|
186
|
-
: parsed.path;
|
|
187
|
-
const key = `${parsed.method} ${normalizedPath}`;
|
|
156
|
+
const key = `${parsed.method} ${normalizePath(parsed.path)}`;
|
|
188
157
|
if (!this.staticRoutes.has(key)) {
|
|
189
158
|
this.staticRoutes.set(key, compiledRoute);
|
|
190
159
|
}
|
|
@@ -224,27 +193,27 @@ export class CallableMockInstance {
|
|
|
224
193
|
// ===== Request Spy / History API =====
|
|
225
194
|
|
|
226
195
|
history(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord[] {
|
|
227
|
-
if (method
|
|
196
|
+
if (method || path) {
|
|
228
197
|
return this.requestHistory.filter(
|
|
229
|
-
(r) => r.method === method && r.path === path,
|
|
198
|
+
(r) => (!method || r.method === method) && (!path || r.path === path),
|
|
230
199
|
);
|
|
231
200
|
}
|
|
232
201
|
return [...this.requestHistory];
|
|
233
202
|
}
|
|
234
203
|
|
|
235
204
|
called(method?: Schmock.HttpMethod, path?: string): boolean {
|
|
236
|
-
if (method
|
|
205
|
+
if (method || path) {
|
|
237
206
|
return this.requestHistory.some(
|
|
238
|
-
(r) => r.method === method && r.path === path,
|
|
207
|
+
(r) => (!method || r.method === method) && (!path || r.path === path),
|
|
239
208
|
);
|
|
240
209
|
}
|
|
241
210
|
return this.requestHistory.length > 0;
|
|
242
211
|
}
|
|
243
212
|
|
|
244
213
|
callCount(method?: Schmock.HttpMethod, path?: string): number {
|
|
245
|
-
if (method
|
|
214
|
+
if (method || path) {
|
|
246
215
|
return this.requestHistory.filter(
|
|
247
|
-
(r) => r.method === method && r.path === path,
|
|
216
|
+
(r) => (!method || r.method === method) && (!path || r.path === path),
|
|
248
217
|
).length;
|
|
249
218
|
}
|
|
250
219
|
return this.requestHistory.length;
|
|
@@ -254,9 +223,9 @@ export class CallableMockInstance {
|
|
|
254
223
|
method?: Schmock.HttpMethod,
|
|
255
224
|
path?: string,
|
|
256
225
|
): Schmock.RequestRecord | undefined {
|
|
257
|
-
if (method
|
|
226
|
+
if (method || path) {
|
|
258
227
|
const filtered = this.requestHistory.filter(
|
|
259
|
-
(r) => r.method === method && r.path === path,
|
|
228
|
+
(r) => (!method || r.method === method) && (!path || r.path === path),
|
|
260
229
|
);
|
|
261
230
|
return filtered[filtered.length - 1];
|
|
262
231
|
}
|
|
@@ -274,7 +243,7 @@ export class CallableMockInstance {
|
|
|
274
243
|
}
|
|
275
244
|
|
|
276
245
|
getState(): Record<string, unknown> {
|
|
277
|
-
return this.globalConfig.state || {};
|
|
246
|
+
return { ...(this.globalConfig.state || {}) };
|
|
278
247
|
}
|
|
279
248
|
|
|
280
249
|
// ===== Lifecycle Events =====
|
|
@@ -351,19 +320,33 @@ export class CallableMockInstance {
|
|
|
351
320
|
}
|
|
352
321
|
|
|
353
322
|
const httpServer = createServer((req, res) => {
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
this.handle(method, path, {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
)
|
|
366
|
-
|
|
323
|
+
const handleRequest = async () => {
|
|
324
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
325
|
+
const method = toHttpMethod(req.method ?? "GET");
|
|
326
|
+
const path = url.pathname;
|
|
327
|
+
const headers = parseNodeHeaders(req);
|
|
328
|
+
const query = parseNodeQuery(url);
|
|
329
|
+
const body = await collectBody(req, headers);
|
|
330
|
+
const schmockResponse = await this.handle(method, path, {
|
|
331
|
+
headers,
|
|
332
|
+
body,
|
|
333
|
+
query,
|
|
334
|
+
});
|
|
335
|
+
writeSchmockResponse(res, schmockResponse);
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
handleRequest().catch((error) => {
|
|
339
|
+
if (!res.headersSent) {
|
|
340
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
341
|
+
}
|
|
342
|
+
res.end(
|
|
343
|
+
JSON.stringify({
|
|
344
|
+
error:
|
|
345
|
+
error instanceof Error ? error.message : "Internal Server Error",
|
|
346
|
+
code: "SERVER_ERROR",
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
});
|
|
367
350
|
});
|
|
368
351
|
|
|
369
352
|
this.server = httpServer;
|
|
@@ -396,11 +379,13 @@ export class CallableMockInstance {
|
|
|
396
379
|
path: string,
|
|
397
380
|
options?: Schmock.RequestOptions,
|
|
398
381
|
): Promise<Schmock.Response> {
|
|
399
|
-
const requestId = crypto.randomUUID();
|
|
400
382
|
const handleStart = performance.now();
|
|
383
|
+
const requestId = this.globalConfig.debug ? crypto.randomUUID() : "";
|
|
384
|
+
const reqQuery = options?.query || {};
|
|
385
|
+
const reqHeaders = options?.headers || {};
|
|
401
386
|
this.logger.log("request", `[${requestId}] ${method} ${path}`, {
|
|
402
|
-
headers:
|
|
403
|
-
query:
|
|
387
|
+
headers: reqHeaders,
|
|
388
|
+
query: reqQuery,
|
|
404
389
|
bodyType: options?.body ? typeof options.body : "none",
|
|
405
390
|
});
|
|
406
391
|
this.logger.time(`request-${requestId}`);
|
|
@@ -408,7 +393,7 @@ export class CallableMockInstance {
|
|
|
408
393
|
this.emit("request:start", {
|
|
409
394
|
method,
|
|
410
395
|
path,
|
|
411
|
-
headers:
|
|
396
|
+
headers: reqHeaders,
|
|
412
397
|
});
|
|
413
398
|
|
|
414
399
|
try {
|
|
@@ -441,6 +426,12 @@ export class CallableMockInstance {
|
|
|
441
426
|
body: { error: error.message, code: error.code },
|
|
442
427
|
headers: {},
|
|
443
428
|
};
|
|
429
|
+
this.emit("request:end", {
|
|
430
|
+
method,
|
|
431
|
+
path,
|
|
432
|
+
status: 404,
|
|
433
|
+
duration: performance.now() - handleStart,
|
|
434
|
+
});
|
|
444
435
|
this.logger.timeEnd(`request-${requestId}`);
|
|
445
436
|
return response;
|
|
446
437
|
}
|
|
@@ -451,7 +442,12 @@ export class CallableMockInstance {
|
|
|
451
442
|
}
|
|
452
443
|
|
|
453
444
|
// Find matching route
|
|
454
|
-
const matchedRoute =
|
|
445
|
+
const matchedRoute = findRoute(
|
|
446
|
+
method,
|
|
447
|
+
requestPath,
|
|
448
|
+
this.staticRoutes,
|
|
449
|
+
this.routes,
|
|
450
|
+
);
|
|
455
451
|
|
|
456
452
|
if (!matchedRoute) {
|
|
457
453
|
this.logger.log(
|
|
@@ -481,7 +477,7 @@ export class CallableMockInstance {
|
|
|
481
477
|
);
|
|
482
478
|
|
|
483
479
|
// Extract parameters from the matched route
|
|
484
|
-
const params =
|
|
480
|
+
const params = extractParams(matchedRoute, requestPath);
|
|
485
481
|
|
|
486
482
|
this.emit("request:match", {
|
|
487
483
|
method,
|
|
@@ -495,8 +491,8 @@ export class CallableMockInstance {
|
|
|
495
491
|
method,
|
|
496
492
|
path: requestPath,
|
|
497
493
|
params,
|
|
498
|
-
query:
|
|
499
|
-
headers:
|
|
494
|
+
query: reqQuery,
|
|
495
|
+
headers: reqHeaders,
|
|
500
496
|
body: options?.body,
|
|
501
497
|
state: this.globalConfig.state || {},
|
|
502
498
|
};
|
|
@@ -514,8 +510,8 @@ export class CallableMockInstance {
|
|
|
514
510
|
route: matchedRoute.config,
|
|
515
511
|
method,
|
|
516
512
|
params,
|
|
517
|
-
query:
|
|
518
|
-
headers:
|
|
513
|
+
query: reqQuery,
|
|
514
|
+
headers: reqHeaders,
|
|
519
515
|
body: options?.body,
|
|
520
516
|
state: new Map(),
|
|
521
517
|
routeState: this.globalConfig.state || {},
|
|
@@ -523,11 +519,11 @@ export class CallableMockInstance {
|
|
|
523
519
|
|
|
524
520
|
// Run plugin pipeline to transform the response
|
|
525
521
|
try {
|
|
526
|
-
const pipelineResult = await
|
|
522
|
+
const pipelineResult = await runPluginPipeline(
|
|
523
|
+
this.plugins,
|
|
527
524
|
pluginContext,
|
|
528
525
|
result,
|
|
529
|
-
|
|
530
|
-
requestId,
|
|
526
|
+
this.logger,
|
|
531
527
|
);
|
|
532
528
|
pluginContext = pipelineResult.context;
|
|
533
529
|
result = pipelineResult.response;
|
|
@@ -540,7 +536,7 @@ export class CallableMockInstance {
|
|
|
540
536
|
}
|
|
541
537
|
|
|
542
538
|
// Parse and prepare response
|
|
543
|
-
const response =
|
|
539
|
+
const response = parseResponse(result, matchedRoute.config);
|
|
544
540
|
|
|
545
541
|
// Apply delay (route-level overrides global)
|
|
546
542
|
await this.applyDelay(matchedRoute.config.delay);
|
|
@@ -550,8 +546,8 @@ export class CallableMockInstance {
|
|
|
550
546
|
method,
|
|
551
547
|
path: requestPath,
|
|
552
548
|
params,
|
|
553
|
-
query:
|
|
554
|
-
headers:
|
|
549
|
+
query: reqQuery,
|
|
550
|
+
headers: reqHeaders,
|
|
555
551
|
body: options?.body,
|
|
556
552
|
timestamp: Date.now(),
|
|
557
553
|
response: { status: response.status, body: response.body },
|
|
@@ -597,6 +593,13 @@ export class CallableMockInstance {
|
|
|
597
593
|
// Apply delay even for error responses
|
|
598
594
|
await this.applyDelay();
|
|
599
595
|
|
|
596
|
+
this.emit("request:end", {
|
|
597
|
+
method,
|
|
598
|
+
path,
|
|
599
|
+
status: 500,
|
|
600
|
+
duration: performance.now() - handleStart,
|
|
601
|
+
});
|
|
602
|
+
|
|
600
603
|
this.logger.log("error", `[${requestId}] Returning error response 500`);
|
|
601
604
|
this.logger.timeEnd(`request-${requestId}`);
|
|
602
605
|
return errorResponse;
|
|
@@ -623,238 +626,4 @@ export class CallableMockInstance {
|
|
|
623
626
|
|
|
624
627
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
625
628
|
}
|
|
626
|
-
|
|
627
|
-
/**
|
|
628
|
-
* Parse and normalize response result into Response object
|
|
629
|
-
* Handles tuple format [status, body, headers], direct values, and response objects
|
|
630
|
-
* @param result - Raw result from generator or plugin
|
|
631
|
-
* @param routeConfig - Route configuration for content-type defaults
|
|
632
|
-
* @returns Normalized Response object with status, body, and headers
|
|
633
|
-
* @private
|
|
634
|
-
*/
|
|
635
|
-
private parseResponse(
|
|
636
|
-
result: unknown,
|
|
637
|
-
routeConfig: Schmock.RouteConfig,
|
|
638
|
-
): Schmock.Response {
|
|
639
|
-
let status = 200;
|
|
640
|
-
let body: unknown = result;
|
|
641
|
-
let headers: Record<string, string> = {};
|
|
642
|
-
|
|
643
|
-
let tupleFormat = false;
|
|
644
|
-
|
|
645
|
-
// Handle already-formed response objects (from plugin error recovery)
|
|
646
|
-
if (isResponseObject(result)) {
|
|
647
|
-
return {
|
|
648
|
-
status: result.status,
|
|
649
|
-
body: result.body,
|
|
650
|
-
headers: result.headers || {},
|
|
651
|
-
};
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
// Handle tuple response format [status, body, headers?]
|
|
655
|
-
if (isStatusTuple(result)) {
|
|
656
|
-
[status, body, headers = {}] = result;
|
|
657
|
-
tupleFormat = true;
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
// Handle null/undefined responses with 204 No Content
|
|
661
|
-
// But don't auto-convert if tuple format was used (status was explicitly provided)
|
|
662
|
-
if (body === null || body === undefined) {
|
|
663
|
-
if (!tupleFormat) {
|
|
664
|
-
status = status === 200 ? 204 : status; // Only change to 204 if status wasn't explicitly set via tuple
|
|
665
|
-
}
|
|
666
|
-
body = undefined; // Ensure body is undefined for null responses
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
// Add content-type header from route config if it exists and headers don't already have it
|
|
670
|
-
// But only if this isn't a tuple response (where headers are explicitly controlled)
|
|
671
|
-
if (!headers["content-type"] && routeConfig.contentType && !tupleFormat) {
|
|
672
|
-
headers["content-type"] = routeConfig.contentType;
|
|
673
|
-
|
|
674
|
-
// Handle special conversion cases when contentType is explicitly set
|
|
675
|
-
if (routeConfig.contentType === "text/plain" && body !== undefined) {
|
|
676
|
-
if (typeof body === "object" && !Buffer.isBuffer(body)) {
|
|
677
|
-
body = JSON.stringify(body);
|
|
678
|
-
} else if (typeof body !== "string") {
|
|
679
|
-
body = String(body);
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
return {
|
|
685
|
-
status,
|
|
686
|
-
body,
|
|
687
|
-
headers,
|
|
688
|
-
};
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
/**
|
|
692
|
-
* Run all registered plugins in sequence
|
|
693
|
-
* First plugin to set response becomes generator, subsequent plugins transform
|
|
694
|
-
* Handles plugin errors via onError hooks
|
|
695
|
-
* @param context - Plugin context with request details
|
|
696
|
-
* @param initialResponse - Initial response from route generator
|
|
697
|
-
* @param _routeConfig - Route config (unused but kept for signature)
|
|
698
|
-
* @param _requestId - Request ID (unused but kept for signature)
|
|
699
|
-
* @returns Updated context and final response after all plugins
|
|
700
|
-
* @private
|
|
701
|
-
*/
|
|
702
|
-
private async runPluginPipeline(
|
|
703
|
-
context: Schmock.PluginContext,
|
|
704
|
-
initialResponse?: unknown,
|
|
705
|
-
_routeConfig?: Schmock.RouteConfig,
|
|
706
|
-
_requestId?: string,
|
|
707
|
-
): Promise<{ context: Schmock.PluginContext; response?: unknown }> {
|
|
708
|
-
let currentContext = context;
|
|
709
|
-
let response: unknown = initialResponse;
|
|
710
|
-
|
|
711
|
-
this.logger.log(
|
|
712
|
-
"pipeline",
|
|
713
|
-
`Running plugin pipeline for ${this.plugins.length} plugins`,
|
|
714
|
-
);
|
|
715
|
-
|
|
716
|
-
for (const plugin of this.plugins) {
|
|
717
|
-
this.logger.log("pipeline", `Processing plugin: ${plugin.name}`);
|
|
718
|
-
|
|
719
|
-
try {
|
|
720
|
-
const result = await plugin.process(currentContext, response);
|
|
721
|
-
|
|
722
|
-
if (!result || !result.context) {
|
|
723
|
-
throw new Error(`Plugin ${plugin.name} didn't return valid result`);
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
currentContext = result.context;
|
|
727
|
-
|
|
728
|
-
// First plugin to set response becomes the generator
|
|
729
|
-
if (
|
|
730
|
-
result.response !== undefined &&
|
|
731
|
-
(response === undefined || response === null)
|
|
732
|
-
) {
|
|
733
|
-
this.logger.log(
|
|
734
|
-
"pipeline",
|
|
735
|
-
`Plugin ${plugin.name} generated response`,
|
|
736
|
-
);
|
|
737
|
-
response = result.response;
|
|
738
|
-
} else if (result.response !== undefined && response !== undefined) {
|
|
739
|
-
this.logger.log(
|
|
740
|
-
"pipeline",
|
|
741
|
-
`Plugin ${plugin.name} transformed response`,
|
|
742
|
-
);
|
|
743
|
-
response = result.response;
|
|
744
|
-
}
|
|
745
|
-
} catch (error) {
|
|
746
|
-
this.logger.log(
|
|
747
|
-
"pipeline",
|
|
748
|
-
`Plugin ${plugin.name} failed: ${errorMessage(error)}`,
|
|
749
|
-
);
|
|
750
|
-
|
|
751
|
-
// Try error handling if plugin has onError hook
|
|
752
|
-
if (plugin.onError) {
|
|
753
|
-
try {
|
|
754
|
-
const pluginError =
|
|
755
|
-
error instanceof Error ? error : new Error(errorMessage(error));
|
|
756
|
-
const errorResult = await plugin.onError(
|
|
757
|
-
pluginError,
|
|
758
|
-
currentContext,
|
|
759
|
-
);
|
|
760
|
-
if (errorResult) {
|
|
761
|
-
this.logger.log(
|
|
762
|
-
"pipeline",
|
|
763
|
-
`Plugin ${plugin.name} handled error`,
|
|
764
|
-
);
|
|
765
|
-
|
|
766
|
-
// Error return → transform the thrown error
|
|
767
|
-
if (errorResult instanceof Error) {
|
|
768
|
-
throw new PluginError(plugin.name, errorResult);
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
// ResponseResult return → recover, stop pipeline
|
|
772
|
-
if (
|
|
773
|
-
typeof errorResult === "object" &&
|
|
774
|
-
errorResult !== null &&
|
|
775
|
-
"status" in errorResult
|
|
776
|
-
) {
|
|
777
|
-
response = errorResult;
|
|
778
|
-
break;
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
// void/falsy return → propagate original error below
|
|
782
|
-
} catch (hookError) {
|
|
783
|
-
// If the hook itself threw (including our PluginError above), re-throw it
|
|
784
|
-
if (hookError instanceof PluginError) {
|
|
785
|
-
throw hookError;
|
|
786
|
-
}
|
|
787
|
-
this.logger.log(
|
|
788
|
-
"pipeline",
|
|
789
|
-
`Plugin ${plugin.name} error handler failed: ${errorMessage(hookError)}`,
|
|
790
|
-
);
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
const cause =
|
|
795
|
-
error instanceof Error ? error : new Error(errorMessage(error));
|
|
796
|
-
throw new PluginError(plugin.name, cause);
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
return { context: currentContext, response };
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
/**
|
|
804
|
-
* Find a route that matches the given method and path
|
|
805
|
-
* Uses two-pass matching: static routes first, then parameterized routes
|
|
806
|
-
* Matches routes in registration order (first registered wins)
|
|
807
|
-
* @param method - HTTP method to match
|
|
808
|
-
* @param path - Request path to match
|
|
809
|
-
* @returns Matched compiled route or undefined if no match
|
|
810
|
-
* @private
|
|
811
|
-
*/
|
|
812
|
-
private findRoute(
|
|
813
|
-
method: Schmock.HttpMethod,
|
|
814
|
-
path: string,
|
|
815
|
-
): CompiledCallableRoute | undefined {
|
|
816
|
-
// O(1) lookup for static routes
|
|
817
|
-
const normalizedPath =
|
|
818
|
-
path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
|
|
819
|
-
const staticMatch = this.staticRoutes.get(`${method} ${normalizedPath}`);
|
|
820
|
-
if (staticMatch) {
|
|
821
|
-
return staticMatch;
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
// Fall through to parameterized route scan
|
|
825
|
-
for (const route of this.routes) {
|
|
826
|
-
if (
|
|
827
|
-
route.method === method &&
|
|
828
|
-
route.params.length > 0 &&
|
|
829
|
-
route.pattern.test(path)
|
|
830
|
-
) {
|
|
831
|
-
return route;
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
return undefined;
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
/**
|
|
839
|
-
* Extract parameter values from path based on route pattern
|
|
840
|
-
* Maps capture groups from regex match to parameter names
|
|
841
|
-
* @param route - Compiled route with pattern and param names
|
|
842
|
-
* @param path - Request path to extract values from
|
|
843
|
-
* @returns Object mapping parameter names to extracted values
|
|
844
|
-
* @private
|
|
845
|
-
*/
|
|
846
|
-
private extractParams(
|
|
847
|
-
route: CompiledCallableRoute,
|
|
848
|
-
path: string,
|
|
849
|
-
): Record<string, string> {
|
|
850
|
-
const match = path.match(route.pattern);
|
|
851
|
-
if (!match) return {};
|
|
852
|
-
|
|
853
|
-
const params: Record<string, string> = {};
|
|
854
|
-
route.params.forEach((param, index) => {
|
|
855
|
-
params[param] = match[index + 1];
|
|
856
|
-
});
|
|
857
|
-
|
|
858
|
-
return params;
|
|
859
|
-
}
|
|
860
629
|
}
|
package/src/constants.ts
CHANGED
|
@@ -24,6 +24,15 @@ export function toHttpMethod(method: string): HttpMethod {
|
|
|
24
24
|
return upper;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
export function normalizePath(path: string): string {
|
|
28
|
+
return path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function toRouteKey(method: HttpMethod, path: string): Schmock.RouteKey {
|
|
32
|
+
const key: `${HttpMethod} ${string}` = `${method} ${path}`;
|
|
33
|
+
return key;
|
|
34
|
+
}
|
|
35
|
+
|
|
27
36
|
/**
|
|
28
37
|
* Check if a value is a status tuple: [status, body] or [status, body, headers]
|
|
29
38
|
* Guards against misinterpreting numeric arrays like [1, 2, 3] as tuples.
|
package/src/errors.ts
CHANGED
package/src/http-helpers.ts
CHANGED
|
@@ -25,18 +25,40 @@ export function parseNodeQuery(url: URL): Record<string, string> {
|
|
|
25
25
|
return query;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** Default body size limit: 10 MB */
|
|
29
|
+
const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024;
|
|
30
|
+
|
|
28
31
|
/**
|
|
29
32
|
* Collect and parse the request body from a Node.js IncomingMessage.
|
|
30
33
|
* Returns parsed JSON if content-type includes "json", otherwise the raw string.
|
|
31
34
|
* Returns undefined for empty bodies.
|
|
35
|
+
* @param req - Node.js IncomingMessage
|
|
36
|
+
* @param headers - Parsed request headers
|
|
37
|
+
* @param maxBodySize - Maximum body size in bytes (default: 10 MB)
|
|
32
38
|
*/
|
|
33
39
|
export function collectBody(
|
|
34
40
|
req: IncomingMessage,
|
|
35
41
|
headers: Record<string, string>,
|
|
42
|
+
maxBodySize = DEFAULT_MAX_BODY_SIZE,
|
|
36
43
|
): Promise<unknown> {
|
|
37
|
-
return new Promise((resolve) => {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
38
45
|
const chunks: Buffer[] = [];
|
|
39
|
-
|
|
46
|
+
let totalSize = 0;
|
|
47
|
+
|
|
48
|
+
req.on("error", reject);
|
|
49
|
+
|
|
50
|
+
req.on("data", (chunk: Buffer) => {
|
|
51
|
+
totalSize += chunk.length;
|
|
52
|
+
if (totalSize > maxBodySize) {
|
|
53
|
+
req.destroy();
|
|
54
|
+
reject(
|
|
55
|
+
Object.assign(new Error("Request body too large"), { status: 413 }),
|
|
56
|
+
);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
chunks.push(chunk);
|
|
60
|
+
});
|
|
61
|
+
|
|
40
62
|
req.on("end", () => {
|
|
41
63
|
const raw = Buffer.concat(chunks).toString();
|
|
42
64
|
if (!raw) {
|