@schmock/core 1.8.0 → 1.9.1
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 +2 -42
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +10 -187
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- 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 +2 -2
- package/src/builder.ts +31 -280
- package/src/errors.ts +4 -0
- package/src/index.ts +10 -4
- package/src/plugin-pipeline.ts +100 -0
- package/src/response-parser.ts +74 -0
- package/src/route-matcher.ts +69 -0
package/dist/builder.d.ts
CHANGED
|
@@ -24,8 +24,8 @@ export declare class CallableMockInstance {
|
|
|
24
24
|
lastRequest(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord | undefined;
|
|
25
25
|
getRoutes(): Schmock.RouteInfo[];
|
|
26
26
|
getState(): Record<string, unknown>;
|
|
27
|
-
on(event:
|
|
28
|
-
off(event:
|
|
27
|
+
on<E extends Schmock.SchmockEvent>(event: E, listener: (data: Schmock.SchmockEventMap[E]) => void): this;
|
|
28
|
+
off<E extends Schmock.SchmockEvent>(event: E, listener: (data: Schmock.SchmockEventMap[E]) => void): this;
|
|
29
29
|
private emit;
|
|
30
30
|
reset(): void;
|
|
31
31
|
resetHistory(): void;
|
|
@@ -39,45 +39,5 @@ export declare class CallableMockInstance {
|
|
|
39
39
|
* @private
|
|
40
40
|
*/
|
|
41
41
|
private applyDelay;
|
|
42
|
-
/**
|
|
43
|
-
* Parse and normalize response result into Response object
|
|
44
|
-
* Handles tuple format [status, body, headers], direct values, and response objects
|
|
45
|
-
* @param result - Raw result from generator or plugin
|
|
46
|
-
* @param routeConfig - Route configuration for content-type defaults
|
|
47
|
-
* @returns Normalized Response object with status, body, and headers
|
|
48
|
-
* @private
|
|
49
|
-
*/
|
|
50
|
-
private parseResponse;
|
|
51
|
-
/**
|
|
52
|
-
* Run all registered plugins in sequence
|
|
53
|
-
* First plugin to set response becomes generator, subsequent plugins transform
|
|
54
|
-
* Handles plugin errors via onError hooks
|
|
55
|
-
* @param context - Plugin context with request details
|
|
56
|
-
* @param initialResponse - Initial response from route generator
|
|
57
|
-
* @param _routeConfig - Route config (unused but kept for signature)
|
|
58
|
-
* @param _requestId - Request ID (unused but kept for signature)
|
|
59
|
-
* @returns Updated context and final response after all plugins
|
|
60
|
-
* @private
|
|
61
|
-
*/
|
|
62
|
-
private runPluginPipeline;
|
|
63
|
-
/**
|
|
64
|
-
* Find a route that matches the given method and path
|
|
65
|
-
* Uses two-pass matching: static routes first, then parameterized routes
|
|
66
|
-
* Matches routes in registration order (first registered wins)
|
|
67
|
-
* @param method - HTTP method to match
|
|
68
|
-
* @param path - Request path to match
|
|
69
|
-
* @returns Matched compiled route or undefined if no match
|
|
70
|
-
* @private
|
|
71
|
-
*/
|
|
72
|
-
private findRoute;
|
|
73
|
-
/**
|
|
74
|
-
* Extract parameter values from path based on route pattern
|
|
75
|
-
* Maps capture groups from regex match to parameter names
|
|
76
|
-
* @param route - Compiled route with pattern and param names
|
|
77
|
-
* @param path - Request path to extract values from
|
|
78
|
-
* @returns Object mapping parameter names to extracted values
|
|
79
|
-
* @private
|
|
80
|
-
*/
|
|
81
|
-
private extractParams;
|
|
82
42
|
}
|
|
83
43
|
//# sourceMappingURL=builder.d.ts.map
|
package/dist/builder.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AAuDA;;;;GAIG;AACH,qBAAa,oBAAoB;IAYnB,OAAO,CAAC,YAAY;IAXhC,OAAO,CAAC,MAAM,CAA+B;IAC7C,OAAO,CAAC,YAAY,CAA4C;IAChE,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,WAAW,CAA2C;IAC9D,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,UAAU,CAAiC;IAEnD,OAAO,CAAC,SAAS,CAAoC;gBAEjC,YAAY,GAAE,OAAO,CAAC,YAAiB;IAa3D,WAAW,CACT,KAAK,EAAE,OAAO,CAAC,QAAQ,EACvB,SAAS,EAAE,OAAO,CAAC,SAAS,EAC5B,MAAM,EAAE,OAAO,CAAC,WAAW,GAC1B,IAAI;IAqFP,cAAc,CAAC,GAAG,EAAE,OAAO,CAAC,oBAAoB,GAAG,IAAI;IAIvD,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,GAAG,IAAI;IAoBlC,OAAO,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE;IAS5E,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO;IAS3D,SAAS,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM;IAS7D,WAAW,CACT,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAC3B,IAAI,CAAC,EAAE,MAAM,GACZ,OAAO,CAAC,aAAa,GAAG,SAAS;IAYpC,SAAS,IAAI,OAAO,CAAC,SAAS,EAAE;IAQhC,QAAQ,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAMnC,EAAE,CAAC,CAAC,SAAS,OAAO,CAAC,YAAY,EAC/B,KAAK,EAAE,CAAC,EACR,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,KAAK,IAAI,GACnD,IAAI;IAUP,GAAG,CAAC,CAAC,SAAS,OAAO,CAAC,YAAY,EAChC,KAAK,EAAE,CAAC,EACR,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,KAAK,IAAI,GACnD,IAAI;IAKP,OAAO,CAAC,IAAI;IAWZ,KAAK,IAAI,IAAI;IAeb,YAAY,IAAI,IAAI;IAKpB,UAAU,IAAI,IAAI;IAWlB,MAAM,CAAC,IAAI,SAAI,EAAE,QAAQ,SAAc,GAAG,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC;IAuCrE,KAAK,IAAI,IAAI;IAUP,MAAM,CACV,MAAM,EAAE,OAAO,CAAC,UAAU,EAC1B,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,OAAO,CAAC,cAAc,GAC/B,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC;IAqN5B;;;;OAIG;YACW,UAAU;CAezB"}
|
package/dist/builder.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { toHttpMethod } from "./constants.js";
|
|
3
|
+
import { errorMessage, RouteDefinitionError, RouteNotFoundError, SchmockError, } from "./errors.js";
|
|
4
4
|
import { collectBody, parseNodeHeaders, parseNodeQuery, writeSchmockResponse, } from "./http-helpers.js";
|
|
5
5
|
import { parseRouteKey } from "./parser.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
6
|
+
import { runPluginPipeline } from "./plugin-pipeline.js";
|
|
7
|
+
import { parseResponse } from "./response-parser.js";
|
|
8
|
+
import { extractParams, findRoute, isGeneratorFunction, } from "./route-matcher.js";
|
|
9
9
|
/**
|
|
10
10
|
* Debug logger that respects debug mode configuration
|
|
11
11
|
*/
|
|
@@ -37,15 +37,6 @@ class DebugLogger {
|
|
|
37
37
|
console.timeEnd(`[SCHMOCK] ${label}`);
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
-
function isGeneratorFunction(gen) {
|
|
41
|
-
return typeof gen === "function";
|
|
42
|
-
}
|
|
43
|
-
function isResponseObject(value) {
|
|
44
|
-
return (typeof value === "object" &&
|
|
45
|
-
value !== null &&
|
|
46
|
-
"status" in value &&
|
|
47
|
-
"body" in value);
|
|
48
|
-
}
|
|
49
40
|
/**
|
|
50
41
|
* Callable mock instance that implements the new API.
|
|
51
42
|
*
|
|
@@ -61,6 +52,7 @@ export class CallableMockInstance {
|
|
|
61
52
|
callableRef;
|
|
62
53
|
server;
|
|
63
54
|
serverInfo;
|
|
55
|
+
// biome-ignore lint/complexity/noBannedTypes: internal storage for event listeners with varying signatures
|
|
64
56
|
listeners = new Map();
|
|
65
57
|
constructor(globalConfig = {}) {
|
|
66
58
|
this.globalConfig = globalConfig;
|
|
@@ -323,7 +315,7 @@ export class CallableMockInstance {
|
|
|
323
315
|
requestPath = stripped.startsWith("/") ? stripped : `/${stripped}`;
|
|
324
316
|
}
|
|
325
317
|
// Find matching route
|
|
326
|
-
const matchedRoute =
|
|
318
|
+
const matchedRoute = findRoute(method, requestPath, this.staticRoutes, this.routes);
|
|
327
319
|
if (!matchedRoute) {
|
|
328
320
|
this.logger.log("route", `[${requestId}] No route found for ${method} ${requestPath}`);
|
|
329
321
|
this.emit("request:notfound", { method, path: requestPath });
|
|
@@ -344,7 +336,7 @@ export class CallableMockInstance {
|
|
|
344
336
|
}
|
|
345
337
|
this.logger.log("route", `[${requestId}] Matched route: ${method} ${matchedRoute.path}`);
|
|
346
338
|
// Extract parameters from the matched route
|
|
347
|
-
const params =
|
|
339
|
+
const params = extractParams(matchedRoute, requestPath);
|
|
348
340
|
this.emit("request:match", {
|
|
349
341
|
method,
|
|
350
342
|
path: requestPath,
|
|
@@ -382,7 +374,7 @@ export class CallableMockInstance {
|
|
|
382
374
|
};
|
|
383
375
|
// Run plugin pipeline to transform the response
|
|
384
376
|
try {
|
|
385
|
-
const pipelineResult = await this.
|
|
377
|
+
const pipelineResult = await runPluginPipeline(this.plugins, pluginContext, result, this.logger);
|
|
386
378
|
pluginContext = pipelineResult.context;
|
|
387
379
|
result = pipelineResult.response;
|
|
388
380
|
}
|
|
@@ -391,7 +383,7 @@ export class CallableMockInstance {
|
|
|
391
383
|
throw error;
|
|
392
384
|
}
|
|
393
385
|
// Parse and prepare response
|
|
394
|
-
const response =
|
|
386
|
+
const response = parseResponse(result, matchedRoute.config);
|
|
395
387
|
// Apply delay (route-level overrides global)
|
|
396
388
|
await this.applyDelay(matchedRoute.config.delay);
|
|
397
389
|
// Record request in history
|
|
@@ -454,173 +446,4 @@ export class CallableMockInstance {
|
|
|
454
446
|
: effectiveDelay;
|
|
455
447
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
456
448
|
}
|
|
457
|
-
/**
|
|
458
|
-
* Parse and normalize response result into Response object
|
|
459
|
-
* Handles tuple format [status, body, headers], direct values, and response objects
|
|
460
|
-
* @param result - Raw result from generator or plugin
|
|
461
|
-
* @param routeConfig - Route configuration for content-type defaults
|
|
462
|
-
* @returns Normalized Response object with status, body, and headers
|
|
463
|
-
* @private
|
|
464
|
-
*/
|
|
465
|
-
parseResponse(result, routeConfig) {
|
|
466
|
-
let status = 200;
|
|
467
|
-
let body = result;
|
|
468
|
-
let headers = {};
|
|
469
|
-
let tupleFormat = false;
|
|
470
|
-
// Handle already-formed response objects (from plugin error recovery)
|
|
471
|
-
if (isResponseObject(result)) {
|
|
472
|
-
return {
|
|
473
|
-
status: result.status,
|
|
474
|
-
body: result.body,
|
|
475
|
-
headers: result.headers || {},
|
|
476
|
-
};
|
|
477
|
-
}
|
|
478
|
-
// Handle tuple response format [status, body, headers?]
|
|
479
|
-
if (isStatusTuple(result)) {
|
|
480
|
-
[status, body, headers = {}] = result;
|
|
481
|
-
tupleFormat = true;
|
|
482
|
-
}
|
|
483
|
-
// Handle null/undefined responses with 204 No Content
|
|
484
|
-
// But don't auto-convert if tuple format was used (status was explicitly provided)
|
|
485
|
-
if (body === null || body === undefined) {
|
|
486
|
-
if (!tupleFormat) {
|
|
487
|
-
status = status === 200 ? 204 : status; // Only change to 204 if status wasn't explicitly set via tuple
|
|
488
|
-
}
|
|
489
|
-
body = undefined; // Ensure body is undefined for null responses
|
|
490
|
-
}
|
|
491
|
-
// Add content-type header from route config if it exists and headers don't already have it
|
|
492
|
-
// But only if this isn't a tuple response (where headers are explicitly controlled)
|
|
493
|
-
if (!headers["content-type"] && routeConfig.contentType && !tupleFormat) {
|
|
494
|
-
headers["content-type"] = routeConfig.contentType;
|
|
495
|
-
// Handle special conversion cases when contentType is explicitly set
|
|
496
|
-
if (routeConfig.contentType === "text/plain" && body !== undefined) {
|
|
497
|
-
if (typeof body === "object" && !Buffer.isBuffer(body)) {
|
|
498
|
-
body = JSON.stringify(body);
|
|
499
|
-
}
|
|
500
|
-
else if (typeof body !== "string") {
|
|
501
|
-
body = String(body);
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
return {
|
|
506
|
-
status,
|
|
507
|
-
body,
|
|
508
|
-
headers,
|
|
509
|
-
};
|
|
510
|
-
}
|
|
511
|
-
/**
|
|
512
|
-
* Run all registered plugins in sequence
|
|
513
|
-
* First plugin to set response becomes generator, subsequent plugins transform
|
|
514
|
-
* Handles plugin errors via onError hooks
|
|
515
|
-
* @param context - Plugin context with request details
|
|
516
|
-
* @param initialResponse - Initial response from route generator
|
|
517
|
-
* @param _routeConfig - Route config (unused but kept for signature)
|
|
518
|
-
* @param _requestId - Request ID (unused but kept for signature)
|
|
519
|
-
* @returns Updated context and final response after all plugins
|
|
520
|
-
* @private
|
|
521
|
-
*/
|
|
522
|
-
async runPluginPipeline(context, initialResponse, _routeConfig, _requestId) {
|
|
523
|
-
let currentContext = context;
|
|
524
|
-
let response = initialResponse;
|
|
525
|
-
this.logger.log("pipeline", `Running plugin pipeline for ${this.plugins.length} plugins`);
|
|
526
|
-
for (const plugin of this.plugins) {
|
|
527
|
-
this.logger.log("pipeline", `Processing plugin: ${plugin.name}`);
|
|
528
|
-
try {
|
|
529
|
-
const result = await plugin.process(currentContext, response);
|
|
530
|
-
if (!result || !result.context) {
|
|
531
|
-
throw new Error(`Plugin ${plugin.name} didn't return valid result`);
|
|
532
|
-
}
|
|
533
|
-
currentContext = result.context;
|
|
534
|
-
// First plugin to set response becomes the generator
|
|
535
|
-
if (result.response !== undefined &&
|
|
536
|
-
(response === undefined || response === null)) {
|
|
537
|
-
this.logger.log("pipeline", `Plugin ${plugin.name} generated response`);
|
|
538
|
-
response = result.response;
|
|
539
|
-
}
|
|
540
|
-
else if (result.response !== undefined && response !== undefined) {
|
|
541
|
-
this.logger.log("pipeline", `Plugin ${plugin.name} transformed response`);
|
|
542
|
-
response = result.response;
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
catch (error) {
|
|
546
|
-
this.logger.log("pipeline", `Plugin ${plugin.name} failed: ${errorMessage(error)}`);
|
|
547
|
-
// Try error handling if plugin has onError hook
|
|
548
|
-
if (plugin.onError) {
|
|
549
|
-
try {
|
|
550
|
-
const pluginError = error instanceof Error ? error : new Error(errorMessage(error));
|
|
551
|
-
const errorResult = await plugin.onError(pluginError, currentContext);
|
|
552
|
-
if (errorResult) {
|
|
553
|
-
this.logger.log("pipeline", `Plugin ${plugin.name} handled error`);
|
|
554
|
-
// Error return → transform the thrown error
|
|
555
|
-
if (errorResult instanceof Error) {
|
|
556
|
-
throw new PluginError(plugin.name, errorResult);
|
|
557
|
-
}
|
|
558
|
-
// ResponseResult return → recover, stop pipeline
|
|
559
|
-
if (typeof errorResult === "object" &&
|
|
560
|
-
errorResult !== null &&
|
|
561
|
-
"status" in errorResult) {
|
|
562
|
-
response = errorResult;
|
|
563
|
-
break;
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
// void/falsy return → propagate original error below
|
|
567
|
-
}
|
|
568
|
-
catch (hookError) {
|
|
569
|
-
// If the hook itself threw (including our PluginError above), re-throw it
|
|
570
|
-
if (hookError instanceof PluginError) {
|
|
571
|
-
throw hookError;
|
|
572
|
-
}
|
|
573
|
-
this.logger.log("pipeline", `Plugin ${plugin.name} error handler failed: ${errorMessage(hookError)}`);
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
const cause = error instanceof Error ? error : new Error(errorMessage(error));
|
|
577
|
-
throw new PluginError(plugin.name, cause);
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
return { context: currentContext, response };
|
|
581
|
-
}
|
|
582
|
-
/**
|
|
583
|
-
* Find a route that matches the given method and path
|
|
584
|
-
* Uses two-pass matching: static routes first, then parameterized routes
|
|
585
|
-
* Matches routes in registration order (first registered wins)
|
|
586
|
-
* @param method - HTTP method to match
|
|
587
|
-
* @param path - Request path to match
|
|
588
|
-
* @returns Matched compiled route or undefined if no match
|
|
589
|
-
* @private
|
|
590
|
-
*/
|
|
591
|
-
findRoute(method, path) {
|
|
592
|
-
// O(1) lookup for static routes
|
|
593
|
-
const normalizedPath = path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
|
|
594
|
-
const staticMatch = this.staticRoutes.get(`${method} ${normalizedPath}`);
|
|
595
|
-
if (staticMatch) {
|
|
596
|
-
return staticMatch;
|
|
597
|
-
}
|
|
598
|
-
// Fall through to parameterized route scan
|
|
599
|
-
for (const route of this.routes) {
|
|
600
|
-
if (route.method === method &&
|
|
601
|
-
route.params.length > 0 &&
|
|
602
|
-
route.pattern.test(path)) {
|
|
603
|
-
return route;
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
return undefined;
|
|
607
|
-
}
|
|
608
|
-
/**
|
|
609
|
-
* Extract parameter values from path based on route pattern
|
|
610
|
-
* Maps capture groups from regex match to parameter names
|
|
611
|
-
* @param route - Compiled route with pattern and param names
|
|
612
|
-
* @param path - Request path to extract values from
|
|
613
|
-
* @returns Object mapping parameter names to extracted values
|
|
614
|
-
* @private
|
|
615
|
-
*/
|
|
616
|
-
extractParams(route, path) {
|
|
617
|
-
const match = path.match(route.pattern);
|
|
618
|
-
if (!match)
|
|
619
|
-
return {};
|
|
620
|
-
const params = {};
|
|
621
|
-
route.params.forEach((param, index) => {
|
|
622
|
-
params[param] = match[index + 1];
|
|
623
|
-
});
|
|
624
|
-
return params;
|
|
625
|
-
}
|
|
626
449
|
}
|
package/dist/errors.d.ts
CHANGED
package/dist/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,qBAAa,YAAa,SAAQ,KAAK;aAGnB,IAAI,EAAE,MAAM;aACZ,OAAO,CAAC,EAAE,OAAO;gBAFjC,OAAO,EAAE,MAAM,EACC,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,OAAO,YAAA;CAQpC;AAED;;GAEG;AACH,qBAAa,kBAAmB,SAAQ,YAAY;gBACtC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;CAOzC;AAED;;GAEG;AACH,qBAAa,eAAgB,SAAQ,YAAY;gBACnC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAQ7C;AAED;;GAEG;AACH,qBAAa,uBAAwB,SAAQ,YAAY;gBAC3C,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK;CAQxC;AAED;;GAEG;AACH,qBAAa,WAAY,SAAQ,YAAY;gBAC/B,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK;CAO7C;AAED;;GAEG;AACH,qBAAa,oBAAqB,SAAQ,YAAY;gBACxC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAQ7C;AAED;;GAEG;AACH,qBAAa,qBAAsB,SAAQ,YAAY;gBACzC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM;CAQnE;AAED;;GAEG;AACH,qBAAa,qBAAsB,SAAQ,YAAY;gBACzC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,CAAC,EAAE,OAAO;CAQ1D;AAED;;GAEG;AACH,qBAAa,kBAAmB,SAAQ,YAAY;gBACtC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM;CAQ7D"}
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEnD;AAED;;GAEG;AACH,qBAAa,YAAa,SAAQ,KAAK;aAGnB,IAAI,EAAE,MAAM;aACZ,OAAO,CAAC,EAAE,OAAO;gBAFjC,OAAO,EAAE,MAAM,EACC,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,OAAO,YAAA;CAQpC;AAED;;GAEG;AACH,qBAAa,kBAAmB,SAAQ,YAAY;gBACtC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;CAOzC;AAED;;GAEG;AACH,qBAAa,eAAgB,SAAQ,YAAY;gBACnC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAQ7C;AAED;;GAEG;AACH,qBAAa,uBAAwB,SAAQ,YAAY;gBAC3C,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK;CAQxC;AAED;;GAEG;AACH,qBAAa,WAAY,SAAQ,YAAY;gBAC/B,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK;CAO7C;AAED;;GAEG;AACH,qBAAa,oBAAqB,SAAQ,YAAY;gBACxC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAQ7C;AAED;;GAEG;AACH,qBAAa,qBAAsB,SAAQ,YAAY;gBACzC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM;CAQnE;AAED;;GAEG;AACH,qBAAa,qBAAsB,SAAQ,YAAY;gBACzC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,CAAC,EAAE,OAAO;CAQ1D;AAED;;GAEG;AACH,qBAAa,kBAAmB,SAAQ,YAAY;gBACtC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM;CAQ7D"}
|
package/dist/errors.js
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,OAAO,CACrB,MAAM,CAAC,EAAE,OAAO,CAAC,YAAY,GAC5B,OAAO,CAAC,oBAAoB,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,OAAO,CACrB,MAAM,CAAC,EAAE,OAAO,CAAC,YAAY,GAC5B,OAAO,CAAC,oBAAoB,CAmD9B;AAGD,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,oBAAoB,EACpB,YAAY,GACb,MAAM,gBAAgB,CAAC;AAExB,OAAO,EACL,WAAW,EACX,kBAAkB,EAClB,uBAAuB,EACvB,oBAAoB,EACpB,kBAAkB,EAClB,eAAe,EACf,qBAAqB,EACrB,qBAAqB,EACrB,YAAY,GACb,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,WAAW,EACX,gBAAgB,EAChB,cAAc,EACd,oBAAoB,GACrB,MAAM,mBAAmB,CAAC;AAE3B,YAAY,EACV,oBAAoB,EACpB,SAAS,EACT,iBAAiB,EACjB,YAAY,EACZ,UAAU,EACV,MAAM,EACN,aAAa,EACb,YAAY,EACZ,cAAc,EACd,cAAc,EACd,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,cAAc,EACd,WAAW,EACX,SAAS,EACT,QAAQ,EACR,UAAU,EACV,UAAU,GACX,MAAM,YAAY,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -42,14 +42,14 @@ export function schmock(config) {
|
|
|
42
42
|
reset: instance.reset.bind(instance),
|
|
43
43
|
resetHistory: instance.resetHistory.bind(instance),
|
|
44
44
|
resetState: instance.resetState.bind(instance),
|
|
45
|
-
on
|
|
45
|
+
on(event, listener) {
|
|
46
46
|
instance.on(event, listener);
|
|
47
47
|
return callableInstance;
|
|
48
|
-
}
|
|
49
|
-
off
|
|
48
|
+
},
|
|
49
|
+
off(event, listener) {
|
|
50
50
|
instance.off(event, listener);
|
|
51
51
|
return callableInstance;
|
|
52
|
-
}
|
|
52
|
+
},
|
|
53
53
|
getRoutes: instance.getRoutes.bind(instance),
|
|
54
54
|
getState: instance.getState.bind(instance),
|
|
55
55
|
listen: instance.listen.bind(instance),
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Structural typing — DebugLogger satisfies this without an import */
|
|
2
|
+
interface PipelineLogger {
|
|
3
|
+
log(category: string, message: string, data?: unknown): void;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Run all registered plugins in sequence
|
|
7
|
+
* First plugin to set response becomes generator, subsequent plugins transform
|
|
8
|
+
* Handles plugin errors via onError hooks
|
|
9
|
+
*/
|
|
10
|
+
export declare function runPluginPipeline(plugins: Schmock.Plugin[], context: Schmock.PluginContext, initialResponse: unknown, logger: PipelineLogger): Promise<{
|
|
11
|
+
context: Schmock.PluginContext;
|
|
12
|
+
response?: unknown;
|
|
13
|
+
}>;
|
|
14
|
+
export {};
|
|
15
|
+
//# sourceMappingURL=plugin-pipeline.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin-pipeline.d.ts","sourceRoot":"","sources":["../src/plugin-pipeline.ts"],"names":[],"mappings":"AAEA,uEAAuE;AACvE,UAAU,cAAc;IACtB,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;CAC9D;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,OAAO,CAAC,MAAM,EAAE,EACzB,OAAO,EAAE,OAAO,CAAC,aAAa,EAC9B,eAAe,EAAE,OAAO,EACxB,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC,aAAa,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,CAkFjE"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { errorMessage, PluginError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Run all registered plugins in sequence
|
|
4
|
+
* First plugin to set response becomes generator, subsequent plugins transform
|
|
5
|
+
* Handles plugin errors via onError hooks
|
|
6
|
+
*/
|
|
7
|
+
export async function runPluginPipeline(plugins, context, initialResponse, logger) {
|
|
8
|
+
let currentContext = context;
|
|
9
|
+
let response = initialResponse;
|
|
10
|
+
logger.log("pipeline", `Running plugin pipeline for ${plugins.length} plugins`);
|
|
11
|
+
for (const plugin of plugins) {
|
|
12
|
+
logger.log("pipeline", `Processing plugin: ${plugin.name}`);
|
|
13
|
+
try {
|
|
14
|
+
const result = await plugin.process(currentContext, response);
|
|
15
|
+
if (!result || !result.context) {
|
|
16
|
+
throw new Error(`Plugin ${plugin.name} didn't return valid result`);
|
|
17
|
+
}
|
|
18
|
+
currentContext = result.context;
|
|
19
|
+
// First plugin to set response becomes the generator
|
|
20
|
+
if (result.response !== undefined &&
|
|
21
|
+
(response === undefined || response === null)) {
|
|
22
|
+
logger.log("pipeline", `Plugin ${plugin.name} generated response`);
|
|
23
|
+
response = result.response;
|
|
24
|
+
}
|
|
25
|
+
else if (result.response !== undefined && response !== undefined) {
|
|
26
|
+
logger.log("pipeline", `Plugin ${plugin.name} transformed response`);
|
|
27
|
+
response = result.response;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
logger.log("pipeline", `Plugin ${plugin.name} failed: ${errorMessage(error)}`);
|
|
32
|
+
// Try error handling if plugin has onError hook
|
|
33
|
+
if (plugin.onError) {
|
|
34
|
+
try {
|
|
35
|
+
const pluginError = error instanceof Error ? error : new Error(errorMessage(error));
|
|
36
|
+
const errorResult = await plugin.onError(pluginError, currentContext);
|
|
37
|
+
if (errorResult) {
|
|
38
|
+
logger.log("pipeline", `Plugin ${plugin.name} handled error`);
|
|
39
|
+
// Error return → transform the thrown error
|
|
40
|
+
if (errorResult instanceof Error) {
|
|
41
|
+
throw new PluginError(plugin.name, errorResult);
|
|
42
|
+
}
|
|
43
|
+
// ResponseResult return → recover, stop pipeline
|
|
44
|
+
if (typeof errorResult === "object" &&
|
|
45
|
+
errorResult !== null &&
|
|
46
|
+
"status" in errorResult) {
|
|
47
|
+
response = errorResult;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// void/falsy return → propagate original error below
|
|
52
|
+
}
|
|
53
|
+
catch (hookError) {
|
|
54
|
+
// If the hook itself threw (including our PluginError above), re-throw it
|
|
55
|
+
if (hookError instanceof PluginError) {
|
|
56
|
+
throw hookError;
|
|
57
|
+
}
|
|
58
|
+
logger.log("pipeline", `Plugin ${plugin.name} error handler failed: ${errorMessage(hookError)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const cause = error instanceof Error ? error : new Error(errorMessage(error));
|
|
62
|
+
throw new PluginError(plugin.name, cause);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { context: currentContext, response };
|
|
66
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse and normalize response result into Response object
|
|
3
|
+
* Handles tuple format [status, body, headers], direct values, and response objects
|
|
4
|
+
*/
|
|
5
|
+
export declare function parseResponse(result: unknown, routeConfig: Schmock.RouteConfig): Schmock.Response;
|
|
6
|
+
//# sourceMappingURL=response-parser.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"response-parser.d.ts","sourceRoot":"","sources":["../src/response-parser.ts"],"names":[],"mappings":"AAeA;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,OAAO,EACf,WAAW,EAAE,OAAO,CAAC,WAAW,GAC/B,OAAO,CAAC,QAAQ,CAmDlB"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { isStatusTuple } from "./constants.js";
|
|
2
|
+
function isResponseObject(value) {
|
|
3
|
+
return (typeof value === "object" &&
|
|
4
|
+
value !== null &&
|
|
5
|
+
"status" in value &&
|
|
6
|
+
"body" in value);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Parse and normalize response result into Response object
|
|
10
|
+
* Handles tuple format [status, body, headers], direct values, and response objects
|
|
11
|
+
*/
|
|
12
|
+
export function parseResponse(result, routeConfig) {
|
|
13
|
+
let status = 200;
|
|
14
|
+
let body = result;
|
|
15
|
+
let headers = {};
|
|
16
|
+
let tupleFormat = false;
|
|
17
|
+
// Handle already-formed response objects (from plugin error recovery)
|
|
18
|
+
if (isResponseObject(result)) {
|
|
19
|
+
return {
|
|
20
|
+
status: result.status,
|
|
21
|
+
body: result.body,
|
|
22
|
+
headers: result.headers || {},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
// Handle tuple response format [status, body, headers?]
|
|
26
|
+
if (isStatusTuple(result)) {
|
|
27
|
+
[status, body, headers = {}] = result;
|
|
28
|
+
tupleFormat = true;
|
|
29
|
+
}
|
|
30
|
+
// Handle null/undefined responses with 204 No Content
|
|
31
|
+
// But don't auto-convert if tuple format was used (status was explicitly provided)
|
|
32
|
+
if (body === null || body === undefined) {
|
|
33
|
+
if (!tupleFormat) {
|
|
34
|
+
status = status === 200 ? 204 : status; // Only change to 204 if status wasn't explicitly set via tuple
|
|
35
|
+
}
|
|
36
|
+
body = undefined; // Ensure body is undefined for null responses
|
|
37
|
+
}
|
|
38
|
+
// Add content-type header from route config if it exists and headers don't already have it
|
|
39
|
+
// But only if this isn't a tuple response (where headers are explicitly controlled)
|
|
40
|
+
if (!headers["content-type"] && routeConfig.contentType && !tupleFormat) {
|
|
41
|
+
headers["content-type"] = routeConfig.contentType;
|
|
42
|
+
// Handle special conversion cases when contentType is explicitly set
|
|
43
|
+
if (routeConfig.contentType === "text/plain" && body !== undefined) {
|
|
44
|
+
if (typeof body === "object" && !Buffer.isBuffer(body)) {
|
|
45
|
+
body = JSON.stringify(body);
|
|
46
|
+
}
|
|
47
|
+
else if (typeof body !== "string") {
|
|
48
|
+
body = String(body);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
status,
|
|
54
|
+
body,
|
|
55
|
+
headers,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiled callable route with pattern matching
|
|
3
|
+
*/
|
|
4
|
+
export interface CompiledCallableRoute {
|
|
5
|
+
pattern: RegExp;
|
|
6
|
+
params: string[];
|
|
7
|
+
method: Schmock.HttpMethod;
|
|
8
|
+
path: string;
|
|
9
|
+
generator: Schmock.Generator;
|
|
10
|
+
config: Schmock.RouteConfig;
|
|
11
|
+
}
|
|
12
|
+
export declare function isGeneratorFunction(gen: Schmock.Generator): gen is Schmock.GeneratorFunction;
|
|
13
|
+
/**
|
|
14
|
+
* Find a route that matches the given method and path
|
|
15
|
+
* Uses two-pass matching: static routes first, then parameterized routes
|
|
16
|
+
* Matches routes in registration order (first registered wins)
|
|
17
|
+
*/
|
|
18
|
+
export declare function findRoute(method: Schmock.HttpMethod, path: string, staticRoutes: Map<string, CompiledCallableRoute>, routes: CompiledCallableRoute[]): CompiledCallableRoute | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Extract parameter values from path based on route pattern
|
|
21
|
+
* Maps capture groups from regex match to parameter names
|
|
22
|
+
*/
|
|
23
|
+
export declare function extractParams(route: CompiledCallableRoute, path: string): Record<string, string>;
|
|
24
|
+
//# sourceMappingURL=route-matcher.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"route-matcher.d.ts","sourceRoot":"","sources":["../src/route-matcher.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC,UAAU,CAAC;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,OAAO,CAAC,SAAS,CAAC;IAC7B,MAAM,EAAE,OAAO,CAAC,WAAW,CAAC;CAC7B;AAED,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,OAAO,CAAC,SAAS,GACrB,GAAG,IAAI,OAAO,CAAC,iBAAiB,CAElC;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,OAAO,CAAC,UAAU,EAC1B,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,qBAAqB,CAAC,EAChD,MAAM,EAAE,qBAAqB,EAAE,GAC9B,qBAAqB,GAAG,SAAS,CAqBnC;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,qBAAqB,EAC5B,IAAI,EAAE,MAAM,GACX,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAUxB"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function isGeneratorFunction(gen) {
|
|
2
|
+
return typeof gen === "function";
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Find a route that matches the given method and path
|
|
6
|
+
* Uses two-pass matching: static routes first, then parameterized routes
|
|
7
|
+
* Matches routes in registration order (first registered wins)
|
|
8
|
+
*/
|
|
9
|
+
export function findRoute(method, path, staticRoutes, routes) {
|
|
10
|
+
// O(1) lookup for static routes
|
|
11
|
+
const normalizedPath = path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
|
|
12
|
+
const staticMatch = staticRoutes.get(`${method} ${normalizedPath}`);
|
|
13
|
+
if (staticMatch) {
|
|
14
|
+
return staticMatch;
|
|
15
|
+
}
|
|
16
|
+
// Fall through to parameterized route scan
|
|
17
|
+
for (const route of routes) {
|
|
18
|
+
if (route.method === method &&
|
|
19
|
+
route.params.length > 0 &&
|
|
20
|
+
route.pattern.test(path)) {
|
|
21
|
+
return route;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Extract parameter values from path based on route pattern
|
|
28
|
+
* Maps capture groups from regex match to parameter names
|
|
29
|
+
*/
|
|
30
|
+
export function extractParams(route, path) {
|
|
31
|
+
const match = path.match(route.pattern);
|
|
32
|
+
if (!match)
|
|
33
|
+
return {};
|
|
34
|
+
const params = {};
|
|
35
|
+
route.params.forEach((param, index) => {
|
|
36
|
+
params[param] = match[index + 1];
|
|
37
|
+
});
|
|
38
|
+
return params;
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@schmock/core",
|
|
3
3
|
"description": "Core functionality for Schmock",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.9.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"license": "MIT",
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@amiceli/vitest-cucumber": "^6.2.0",
|
|
36
|
-
"@types/node": "^25.2.
|
|
36
|
+
"@types/node": "^25.2.3",
|
|
37
37
|
"@vitest/ui": "^4.0.18",
|
|
38
38
|
"vitest": "^4.0.18"
|
|
39
39
|
}
|
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 { 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
|
*
|
|
@@ -94,7 +67,8 @@ export class CallableMockInstance {
|
|
|
94
67
|
private callableRef: Schmock.CallableMockInstance | undefined;
|
|
95
68
|
private server: Server | undefined;
|
|
96
69
|
private serverInfo: Schmock.ServerInfo | undefined;
|
|
97
|
-
|
|
70
|
+
// biome-ignore lint/complexity/noBannedTypes: internal storage for event listeners with varying signatures
|
|
71
|
+
private listeners = new Map<string, Set<Function>>();
|
|
98
72
|
|
|
99
73
|
constructor(private globalConfig: Schmock.GlobalConfig = {}) {
|
|
100
74
|
this.logger = new DebugLogger(globalConfig.debug || false);
|
|
@@ -278,7 +252,10 @@ export class CallableMockInstance {
|
|
|
278
252
|
|
|
279
253
|
// ===== Lifecycle Events =====
|
|
280
254
|
|
|
281
|
-
on
|
|
255
|
+
on<E extends Schmock.SchmockEvent>(
|
|
256
|
+
event: E,
|
|
257
|
+
listener: (data: Schmock.SchmockEventMap[E]) => void,
|
|
258
|
+
): this {
|
|
282
259
|
let set = this.listeners.get(event);
|
|
283
260
|
if (!set) {
|
|
284
261
|
set = new Set();
|
|
@@ -288,7 +265,10 @@ export class CallableMockInstance {
|
|
|
288
265
|
return this;
|
|
289
266
|
}
|
|
290
267
|
|
|
291
|
-
off
|
|
268
|
+
off<E extends Schmock.SchmockEvent>(
|
|
269
|
+
event: E,
|
|
270
|
+
listener: (data: Schmock.SchmockEventMap[E]) => void,
|
|
271
|
+
): this {
|
|
292
272
|
this.listeners.get(event)?.delete(listener);
|
|
293
273
|
return this;
|
|
294
274
|
}
|
|
@@ -444,7 +424,12 @@ export class CallableMockInstance {
|
|
|
444
424
|
}
|
|
445
425
|
|
|
446
426
|
// Find matching route
|
|
447
|
-
const matchedRoute =
|
|
427
|
+
const matchedRoute = findRoute(
|
|
428
|
+
method,
|
|
429
|
+
requestPath,
|
|
430
|
+
this.staticRoutes,
|
|
431
|
+
this.routes,
|
|
432
|
+
);
|
|
448
433
|
|
|
449
434
|
if (!matchedRoute) {
|
|
450
435
|
this.logger.log(
|
|
@@ -474,7 +459,7 @@ export class CallableMockInstance {
|
|
|
474
459
|
);
|
|
475
460
|
|
|
476
461
|
// Extract parameters from the matched route
|
|
477
|
-
const params =
|
|
462
|
+
const params = extractParams(matchedRoute, requestPath);
|
|
478
463
|
|
|
479
464
|
this.emit("request:match", {
|
|
480
465
|
method,
|
|
@@ -516,11 +501,11 @@ export class CallableMockInstance {
|
|
|
516
501
|
|
|
517
502
|
// Run plugin pipeline to transform the response
|
|
518
503
|
try {
|
|
519
|
-
const pipelineResult = await
|
|
504
|
+
const pipelineResult = await runPluginPipeline(
|
|
505
|
+
this.plugins,
|
|
520
506
|
pluginContext,
|
|
521
507
|
result,
|
|
522
|
-
|
|
523
|
-
requestId,
|
|
508
|
+
this.logger,
|
|
524
509
|
);
|
|
525
510
|
pluginContext = pipelineResult.context;
|
|
526
511
|
result = pipelineResult.response;
|
|
@@ -533,7 +518,7 @@ export class CallableMockInstance {
|
|
|
533
518
|
}
|
|
534
519
|
|
|
535
520
|
// Parse and prepare response
|
|
536
|
-
const response =
|
|
521
|
+
const response = parseResponse(result, matchedRoute.config);
|
|
537
522
|
|
|
538
523
|
// Apply delay (route-level overrides global)
|
|
539
524
|
await this.applyDelay(matchedRoute.config.delay);
|
|
@@ -616,238 +601,4 @@ export class CallableMockInstance {
|
|
|
616
601
|
|
|
617
602
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
618
603
|
}
|
|
619
|
-
|
|
620
|
-
/**
|
|
621
|
-
* Parse and normalize response result into Response object
|
|
622
|
-
* Handles tuple format [status, body, headers], direct values, and response objects
|
|
623
|
-
* @param result - Raw result from generator or plugin
|
|
624
|
-
* @param routeConfig - Route configuration for content-type defaults
|
|
625
|
-
* @returns Normalized Response object with status, body, and headers
|
|
626
|
-
* @private
|
|
627
|
-
*/
|
|
628
|
-
private parseResponse(
|
|
629
|
-
result: unknown,
|
|
630
|
-
routeConfig: Schmock.RouteConfig,
|
|
631
|
-
): Schmock.Response {
|
|
632
|
-
let status = 200;
|
|
633
|
-
let body: unknown = result;
|
|
634
|
-
let headers: Record<string, string> = {};
|
|
635
|
-
|
|
636
|
-
let tupleFormat = false;
|
|
637
|
-
|
|
638
|
-
// Handle already-formed response objects (from plugin error recovery)
|
|
639
|
-
if (isResponseObject(result)) {
|
|
640
|
-
return {
|
|
641
|
-
status: result.status,
|
|
642
|
-
body: result.body,
|
|
643
|
-
headers: result.headers || {},
|
|
644
|
-
};
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// Handle tuple response format [status, body, headers?]
|
|
648
|
-
if (isStatusTuple(result)) {
|
|
649
|
-
[status, body, headers = {}] = result;
|
|
650
|
-
tupleFormat = true;
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
// Handle null/undefined responses with 204 No Content
|
|
654
|
-
// But don't auto-convert if tuple format was used (status was explicitly provided)
|
|
655
|
-
if (body === null || body === undefined) {
|
|
656
|
-
if (!tupleFormat) {
|
|
657
|
-
status = status === 200 ? 204 : status; // Only change to 204 if status wasn't explicitly set via tuple
|
|
658
|
-
}
|
|
659
|
-
body = undefined; // Ensure body is undefined for null responses
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
// Add content-type header from route config if it exists and headers don't already have it
|
|
663
|
-
// But only if this isn't a tuple response (where headers are explicitly controlled)
|
|
664
|
-
if (!headers["content-type"] && routeConfig.contentType && !tupleFormat) {
|
|
665
|
-
headers["content-type"] = routeConfig.contentType;
|
|
666
|
-
|
|
667
|
-
// Handle special conversion cases when contentType is explicitly set
|
|
668
|
-
if (routeConfig.contentType === "text/plain" && body !== undefined) {
|
|
669
|
-
if (typeof body === "object" && !Buffer.isBuffer(body)) {
|
|
670
|
-
body = JSON.stringify(body);
|
|
671
|
-
} else if (typeof body !== "string") {
|
|
672
|
-
body = String(body);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
return {
|
|
678
|
-
status,
|
|
679
|
-
body,
|
|
680
|
-
headers,
|
|
681
|
-
};
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
/**
|
|
685
|
-
* Run all registered plugins in sequence
|
|
686
|
-
* First plugin to set response becomes generator, subsequent plugins transform
|
|
687
|
-
* Handles plugin errors via onError hooks
|
|
688
|
-
* @param context - Plugin context with request details
|
|
689
|
-
* @param initialResponse - Initial response from route generator
|
|
690
|
-
* @param _routeConfig - Route config (unused but kept for signature)
|
|
691
|
-
* @param _requestId - Request ID (unused but kept for signature)
|
|
692
|
-
* @returns Updated context and final response after all plugins
|
|
693
|
-
* @private
|
|
694
|
-
*/
|
|
695
|
-
private async runPluginPipeline(
|
|
696
|
-
context: Schmock.PluginContext,
|
|
697
|
-
initialResponse?: unknown,
|
|
698
|
-
_routeConfig?: Schmock.RouteConfig,
|
|
699
|
-
_requestId?: string,
|
|
700
|
-
): Promise<{ context: Schmock.PluginContext; response?: unknown }> {
|
|
701
|
-
let currentContext = context;
|
|
702
|
-
let response: unknown = initialResponse;
|
|
703
|
-
|
|
704
|
-
this.logger.log(
|
|
705
|
-
"pipeline",
|
|
706
|
-
`Running plugin pipeline for ${this.plugins.length} plugins`,
|
|
707
|
-
);
|
|
708
|
-
|
|
709
|
-
for (const plugin of this.plugins) {
|
|
710
|
-
this.logger.log("pipeline", `Processing plugin: ${plugin.name}`);
|
|
711
|
-
|
|
712
|
-
try {
|
|
713
|
-
const result = await plugin.process(currentContext, response);
|
|
714
|
-
|
|
715
|
-
if (!result || !result.context) {
|
|
716
|
-
throw new Error(`Plugin ${plugin.name} didn't return valid result`);
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
currentContext = result.context;
|
|
720
|
-
|
|
721
|
-
// First plugin to set response becomes the generator
|
|
722
|
-
if (
|
|
723
|
-
result.response !== undefined &&
|
|
724
|
-
(response === undefined || response === null)
|
|
725
|
-
) {
|
|
726
|
-
this.logger.log(
|
|
727
|
-
"pipeline",
|
|
728
|
-
`Plugin ${plugin.name} generated response`,
|
|
729
|
-
);
|
|
730
|
-
response = result.response;
|
|
731
|
-
} else if (result.response !== undefined && response !== undefined) {
|
|
732
|
-
this.logger.log(
|
|
733
|
-
"pipeline",
|
|
734
|
-
`Plugin ${plugin.name} transformed response`,
|
|
735
|
-
);
|
|
736
|
-
response = result.response;
|
|
737
|
-
}
|
|
738
|
-
} catch (error) {
|
|
739
|
-
this.logger.log(
|
|
740
|
-
"pipeline",
|
|
741
|
-
`Plugin ${plugin.name} failed: ${errorMessage(error)}`,
|
|
742
|
-
);
|
|
743
|
-
|
|
744
|
-
// Try error handling if plugin has onError hook
|
|
745
|
-
if (plugin.onError) {
|
|
746
|
-
try {
|
|
747
|
-
const pluginError =
|
|
748
|
-
error instanceof Error ? error : new Error(errorMessage(error));
|
|
749
|
-
const errorResult = await plugin.onError(
|
|
750
|
-
pluginError,
|
|
751
|
-
currentContext,
|
|
752
|
-
);
|
|
753
|
-
if (errorResult) {
|
|
754
|
-
this.logger.log(
|
|
755
|
-
"pipeline",
|
|
756
|
-
`Plugin ${plugin.name} handled error`,
|
|
757
|
-
);
|
|
758
|
-
|
|
759
|
-
// Error return → transform the thrown error
|
|
760
|
-
if (errorResult instanceof Error) {
|
|
761
|
-
throw new PluginError(plugin.name, errorResult);
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
// ResponseResult return → recover, stop pipeline
|
|
765
|
-
if (
|
|
766
|
-
typeof errorResult === "object" &&
|
|
767
|
-
errorResult !== null &&
|
|
768
|
-
"status" in errorResult
|
|
769
|
-
) {
|
|
770
|
-
response = errorResult;
|
|
771
|
-
break;
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
// void/falsy return → propagate original error below
|
|
775
|
-
} catch (hookError) {
|
|
776
|
-
// If the hook itself threw (including our PluginError above), re-throw it
|
|
777
|
-
if (hookError instanceof PluginError) {
|
|
778
|
-
throw hookError;
|
|
779
|
-
}
|
|
780
|
-
this.logger.log(
|
|
781
|
-
"pipeline",
|
|
782
|
-
`Plugin ${plugin.name} error handler failed: ${errorMessage(hookError)}`,
|
|
783
|
-
);
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
const cause =
|
|
788
|
-
error instanceof Error ? error : new Error(errorMessage(error));
|
|
789
|
-
throw new PluginError(plugin.name, cause);
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
return { context: currentContext, response };
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
/**
|
|
797
|
-
* Find a route that matches the given method and path
|
|
798
|
-
* Uses two-pass matching: static routes first, then parameterized routes
|
|
799
|
-
* Matches routes in registration order (first registered wins)
|
|
800
|
-
* @param method - HTTP method to match
|
|
801
|
-
* @param path - Request path to match
|
|
802
|
-
* @returns Matched compiled route or undefined if no match
|
|
803
|
-
* @private
|
|
804
|
-
*/
|
|
805
|
-
private findRoute(
|
|
806
|
-
method: Schmock.HttpMethod,
|
|
807
|
-
path: string,
|
|
808
|
-
): CompiledCallableRoute | undefined {
|
|
809
|
-
// O(1) lookup for static routes
|
|
810
|
-
const normalizedPath =
|
|
811
|
-
path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
|
|
812
|
-
const staticMatch = this.staticRoutes.get(`${method} ${normalizedPath}`);
|
|
813
|
-
if (staticMatch) {
|
|
814
|
-
return staticMatch;
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
// Fall through to parameterized route scan
|
|
818
|
-
for (const route of this.routes) {
|
|
819
|
-
if (
|
|
820
|
-
route.method === method &&
|
|
821
|
-
route.params.length > 0 &&
|
|
822
|
-
route.pattern.test(path)
|
|
823
|
-
) {
|
|
824
|
-
return route;
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
return undefined;
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
/**
|
|
832
|
-
* Extract parameter values from path based on route pattern
|
|
833
|
-
* Maps capture groups from regex match to parameter names
|
|
834
|
-
* @param route - Compiled route with pattern and param names
|
|
835
|
-
* @param path - Request path to extract values from
|
|
836
|
-
* @returns Object mapping parameter names to extracted values
|
|
837
|
-
* @private
|
|
838
|
-
*/
|
|
839
|
-
private extractParams(
|
|
840
|
-
route: CompiledCallableRoute,
|
|
841
|
-
path: string,
|
|
842
|
-
): Record<string, string> {
|
|
843
|
-
const match = path.match(route.pattern);
|
|
844
|
-
if (!match) return {};
|
|
845
|
-
|
|
846
|
-
const params: Record<string, string> = {};
|
|
847
|
-
route.params.forEach((param, index) => {
|
|
848
|
-
params[param] = match[index + 1];
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
return params;
|
|
852
|
-
}
|
|
853
604
|
}
|
package/src/errors.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -52,14 +52,20 @@ export function schmock(
|
|
|
52
52
|
reset: instance.reset.bind(instance),
|
|
53
53
|
resetHistory: instance.resetHistory.bind(instance),
|
|
54
54
|
resetState: instance.resetState.bind(instance),
|
|
55
|
-
on
|
|
55
|
+
on<E extends Schmock.SchmockEvent>(
|
|
56
|
+
event: E,
|
|
57
|
+
listener: (data: Schmock.SchmockEventMap[E]) => void,
|
|
58
|
+
) {
|
|
56
59
|
instance.on(event, listener);
|
|
57
60
|
return callableInstance;
|
|
58
|
-
}
|
|
59
|
-
off
|
|
61
|
+
},
|
|
62
|
+
off<E extends Schmock.SchmockEvent>(
|
|
63
|
+
event: E,
|
|
64
|
+
listener: (data: Schmock.SchmockEventMap[E]) => void,
|
|
65
|
+
) {
|
|
60
66
|
instance.off(event, listener);
|
|
61
67
|
return callableInstance;
|
|
62
|
-
}
|
|
68
|
+
},
|
|
63
69
|
getRoutes: instance.getRoutes.bind(instance),
|
|
64
70
|
getState: instance.getState.bind(instance),
|
|
65
71
|
listen: instance.listen.bind(instance),
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { errorMessage, PluginError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
/** Structural typing — DebugLogger satisfies this without an import */
|
|
4
|
+
interface PipelineLogger {
|
|
5
|
+
log(category: string, message: string, data?: unknown): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Run all registered plugins in sequence
|
|
10
|
+
* First plugin to set response becomes generator, subsequent plugins transform
|
|
11
|
+
* Handles plugin errors via onError hooks
|
|
12
|
+
*/
|
|
13
|
+
export async function runPluginPipeline(
|
|
14
|
+
plugins: Schmock.Plugin[],
|
|
15
|
+
context: Schmock.PluginContext,
|
|
16
|
+
initialResponse: unknown,
|
|
17
|
+
logger: PipelineLogger,
|
|
18
|
+
): Promise<{ context: Schmock.PluginContext; response?: unknown }> {
|
|
19
|
+
let currentContext = context;
|
|
20
|
+
let response: unknown = initialResponse;
|
|
21
|
+
|
|
22
|
+
logger.log(
|
|
23
|
+
"pipeline",
|
|
24
|
+
`Running plugin pipeline for ${plugins.length} plugins`,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
for (const plugin of plugins) {
|
|
28
|
+
logger.log("pipeline", `Processing plugin: ${plugin.name}`);
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const result = await plugin.process(currentContext, response);
|
|
32
|
+
|
|
33
|
+
if (!result || !result.context) {
|
|
34
|
+
throw new Error(`Plugin ${plugin.name} didn't return valid result`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
currentContext = result.context;
|
|
38
|
+
|
|
39
|
+
// First plugin to set response becomes the generator
|
|
40
|
+
if (
|
|
41
|
+
result.response !== undefined &&
|
|
42
|
+
(response === undefined || response === null)
|
|
43
|
+
) {
|
|
44
|
+
logger.log("pipeline", `Plugin ${plugin.name} generated response`);
|
|
45
|
+
response = result.response;
|
|
46
|
+
} else if (result.response !== undefined && response !== undefined) {
|
|
47
|
+
logger.log("pipeline", `Plugin ${plugin.name} transformed response`);
|
|
48
|
+
response = result.response;
|
|
49
|
+
}
|
|
50
|
+
} catch (error) {
|
|
51
|
+
logger.log(
|
|
52
|
+
"pipeline",
|
|
53
|
+
`Plugin ${plugin.name} failed: ${errorMessage(error)}`,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Try error handling if plugin has onError hook
|
|
57
|
+
if (plugin.onError) {
|
|
58
|
+
try {
|
|
59
|
+
const pluginError =
|
|
60
|
+
error instanceof Error ? error : new Error(errorMessage(error));
|
|
61
|
+
const errorResult = await plugin.onError(pluginError, currentContext);
|
|
62
|
+
if (errorResult) {
|
|
63
|
+
logger.log("pipeline", `Plugin ${plugin.name} handled error`);
|
|
64
|
+
|
|
65
|
+
// Error return → transform the thrown error
|
|
66
|
+
if (errorResult instanceof Error) {
|
|
67
|
+
throw new PluginError(plugin.name, errorResult);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ResponseResult return → recover, stop pipeline
|
|
71
|
+
if (
|
|
72
|
+
typeof errorResult === "object" &&
|
|
73
|
+
errorResult !== null &&
|
|
74
|
+
"status" in errorResult
|
|
75
|
+
) {
|
|
76
|
+
response = errorResult;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// void/falsy return → propagate original error below
|
|
81
|
+
} catch (hookError) {
|
|
82
|
+
// If the hook itself threw (including our PluginError above), re-throw it
|
|
83
|
+
if (hookError instanceof PluginError) {
|
|
84
|
+
throw hookError;
|
|
85
|
+
}
|
|
86
|
+
logger.log(
|
|
87
|
+
"pipeline",
|
|
88
|
+
`Plugin ${plugin.name} error handler failed: ${errorMessage(hookError)}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const cause =
|
|
94
|
+
error instanceof Error ? error : new Error(errorMessage(error));
|
|
95
|
+
throw new PluginError(plugin.name, cause);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { context: currentContext, response };
|
|
100
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { isStatusTuple } from "./constants.js";
|
|
2
|
+
|
|
3
|
+
function isResponseObject(value: unknown): value is {
|
|
4
|
+
status: number;
|
|
5
|
+
body: unknown;
|
|
6
|
+
headers?: Record<string, string>;
|
|
7
|
+
} {
|
|
8
|
+
return (
|
|
9
|
+
typeof value === "object" &&
|
|
10
|
+
value !== null &&
|
|
11
|
+
"status" in value &&
|
|
12
|
+
"body" in value
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse and normalize response result into Response object
|
|
18
|
+
* Handles tuple format [status, body, headers], direct values, and response objects
|
|
19
|
+
*/
|
|
20
|
+
export function parseResponse(
|
|
21
|
+
result: unknown,
|
|
22
|
+
routeConfig: Schmock.RouteConfig,
|
|
23
|
+
): Schmock.Response {
|
|
24
|
+
let status = 200;
|
|
25
|
+
let body: unknown = result;
|
|
26
|
+
let headers: Record<string, string> = {};
|
|
27
|
+
|
|
28
|
+
let tupleFormat = false;
|
|
29
|
+
|
|
30
|
+
// Handle already-formed response objects (from plugin error recovery)
|
|
31
|
+
if (isResponseObject(result)) {
|
|
32
|
+
return {
|
|
33
|
+
status: result.status,
|
|
34
|
+
body: result.body,
|
|
35
|
+
headers: result.headers || {},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Handle tuple response format [status, body, headers?]
|
|
40
|
+
if (isStatusTuple(result)) {
|
|
41
|
+
[status, body, headers = {}] = result;
|
|
42
|
+
tupleFormat = true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Handle null/undefined responses with 204 No Content
|
|
46
|
+
// But don't auto-convert if tuple format was used (status was explicitly provided)
|
|
47
|
+
if (body === null || body === undefined) {
|
|
48
|
+
if (!tupleFormat) {
|
|
49
|
+
status = status === 200 ? 204 : status; // Only change to 204 if status wasn't explicitly set via tuple
|
|
50
|
+
}
|
|
51
|
+
body = undefined; // Ensure body is undefined for null responses
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Add content-type header from route config if it exists and headers don't already have it
|
|
55
|
+
// But only if this isn't a tuple response (where headers are explicitly controlled)
|
|
56
|
+
if (!headers["content-type"] && routeConfig.contentType && !tupleFormat) {
|
|
57
|
+
headers["content-type"] = routeConfig.contentType;
|
|
58
|
+
|
|
59
|
+
// Handle special conversion cases when contentType is explicitly set
|
|
60
|
+
if (routeConfig.contentType === "text/plain" && body !== undefined) {
|
|
61
|
+
if (typeof body === "object" && !Buffer.isBuffer(body)) {
|
|
62
|
+
body = JSON.stringify(body);
|
|
63
|
+
} else if (typeof body !== "string") {
|
|
64
|
+
body = String(body);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
status,
|
|
71
|
+
body,
|
|
72
|
+
headers,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiled callable route with pattern matching
|
|
3
|
+
*/
|
|
4
|
+
export interface CompiledCallableRoute {
|
|
5
|
+
pattern: RegExp;
|
|
6
|
+
params: string[];
|
|
7
|
+
method: Schmock.HttpMethod;
|
|
8
|
+
path: string;
|
|
9
|
+
generator: Schmock.Generator;
|
|
10
|
+
config: Schmock.RouteConfig;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isGeneratorFunction(
|
|
14
|
+
gen: Schmock.Generator,
|
|
15
|
+
): gen is Schmock.GeneratorFunction {
|
|
16
|
+
return typeof gen === "function";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Find a route that matches the given method and path
|
|
21
|
+
* Uses two-pass matching: static routes first, then parameterized routes
|
|
22
|
+
* Matches routes in registration order (first registered wins)
|
|
23
|
+
*/
|
|
24
|
+
export function findRoute(
|
|
25
|
+
method: Schmock.HttpMethod,
|
|
26
|
+
path: string,
|
|
27
|
+
staticRoutes: Map<string, CompiledCallableRoute>,
|
|
28
|
+
routes: CompiledCallableRoute[],
|
|
29
|
+
): CompiledCallableRoute | undefined {
|
|
30
|
+
// O(1) lookup for static routes
|
|
31
|
+
const normalizedPath =
|
|
32
|
+
path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
|
|
33
|
+
const staticMatch = staticRoutes.get(`${method} ${normalizedPath}`);
|
|
34
|
+
if (staticMatch) {
|
|
35
|
+
return staticMatch;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Fall through to parameterized route scan
|
|
39
|
+
for (const route of routes) {
|
|
40
|
+
if (
|
|
41
|
+
route.method === method &&
|
|
42
|
+
route.params.length > 0 &&
|
|
43
|
+
route.pattern.test(path)
|
|
44
|
+
) {
|
|
45
|
+
return route;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract parameter values from path based on route pattern
|
|
54
|
+
* Maps capture groups from regex match to parameter names
|
|
55
|
+
*/
|
|
56
|
+
export function extractParams(
|
|
57
|
+
route: CompiledCallableRoute,
|
|
58
|
+
path: string,
|
|
59
|
+
): Record<string, string> {
|
|
60
|
+
const match = path.match(route.pattern);
|
|
61
|
+
if (!match) return {};
|
|
62
|
+
|
|
63
|
+
const params: Record<string, string> = {};
|
|
64
|
+
route.params.forEach((param, index) => {
|
|
65
|
+
params[param] = match[index + 1];
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return params;
|
|
69
|
+
}
|