@schmock/core 1.9.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 +0 -40
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +9 -187
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -0
- 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 +1 -1
- package/src/builder.ts +21 -277
- package/src/errors.ts +4 -0
- 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
|
@@ -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
|
*
|
|
@@ -324,7 +315,7 @@ export class CallableMockInstance {
|
|
|
324
315
|
requestPath = stripped.startsWith("/") ? stripped : `/${stripped}`;
|
|
325
316
|
}
|
|
326
317
|
// Find matching route
|
|
327
|
-
const matchedRoute =
|
|
318
|
+
const matchedRoute = findRoute(method, requestPath, this.staticRoutes, this.routes);
|
|
328
319
|
if (!matchedRoute) {
|
|
329
320
|
this.logger.log("route", `[${requestId}] No route found for ${method} ${requestPath}`);
|
|
330
321
|
this.emit("request:notfound", { method, path: requestPath });
|
|
@@ -345,7 +336,7 @@ export class CallableMockInstance {
|
|
|
345
336
|
}
|
|
346
337
|
this.logger.log("route", `[${requestId}] Matched route: ${method} ${matchedRoute.path}`);
|
|
347
338
|
// Extract parameters from the matched route
|
|
348
|
-
const params =
|
|
339
|
+
const params = extractParams(matchedRoute, requestPath);
|
|
349
340
|
this.emit("request:match", {
|
|
350
341
|
method,
|
|
351
342
|
path: requestPath,
|
|
@@ -383,7 +374,7 @@ export class CallableMockInstance {
|
|
|
383
374
|
};
|
|
384
375
|
// Run plugin pipeline to transform the response
|
|
385
376
|
try {
|
|
386
|
-
const pipelineResult = await this.
|
|
377
|
+
const pipelineResult = await runPluginPipeline(this.plugins, pluginContext, result, this.logger);
|
|
387
378
|
pluginContext = pipelineResult.context;
|
|
388
379
|
result = pipelineResult.response;
|
|
389
380
|
}
|
|
@@ -392,7 +383,7 @@ export class CallableMockInstance {
|
|
|
392
383
|
throw error;
|
|
393
384
|
}
|
|
394
385
|
// Parse and prepare response
|
|
395
|
-
const response =
|
|
386
|
+
const response = parseResponse(result, matchedRoute.config);
|
|
396
387
|
// Apply delay (route-level overrides global)
|
|
397
388
|
await this.applyDelay(matchedRoute.config.delay);
|
|
398
389
|
// Record request in history
|
|
@@ -455,173 +446,4 @@ export class CallableMockInstance {
|
|
|
455
446
|
: effectiveDelay;
|
|
456
447
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
457
448
|
}
|
|
458
|
-
/**
|
|
459
|
-
* Parse and normalize response result into Response object
|
|
460
|
-
* Handles tuple format [status, body, headers], direct values, and response objects
|
|
461
|
-
* @param result - Raw result from generator or plugin
|
|
462
|
-
* @param routeConfig - Route configuration for content-type defaults
|
|
463
|
-
* @returns Normalized Response object with status, body, and headers
|
|
464
|
-
* @private
|
|
465
|
-
*/
|
|
466
|
-
parseResponse(result, routeConfig) {
|
|
467
|
-
let status = 200;
|
|
468
|
-
let body = result;
|
|
469
|
-
let headers = {};
|
|
470
|
-
let tupleFormat = false;
|
|
471
|
-
// Handle already-formed response objects (from plugin error recovery)
|
|
472
|
-
if (isResponseObject(result)) {
|
|
473
|
-
return {
|
|
474
|
-
status: result.status,
|
|
475
|
-
body: result.body,
|
|
476
|
-
headers: result.headers || {},
|
|
477
|
-
};
|
|
478
|
-
}
|
|
479
|
-
// Handle tuple response format [status, body, headers?]
|
|
480
|
-
if (isStatusTuple(result)) {
|
|
481
|
-
[status, body, headers = {}] = result;
|
|
482
|
-
tupleFormat = true;
|
|
483
|
-
}
|
|
484
|
-
// Handle null/undefined responses with 204 No Content
|
|
485
|
-
// But don't auto-convert if tuple format was used (status was explicitly provided)
|
|
486
|
-
if (body === null || body === undefined) {
|
|
487
|
-
if (!tupleFormat) {
|
|
488
|
-
status = status === 200 ? 204 : status; // Only change to 204 if status wasn't explicitly set via tuple
|
|
489
|
-
}
|
|
490
|
-
body = undefined; // Ensure body is undefined for null responses
|
|
491
|
-
}
|
|
492
|
-
// Add content-type header from route config if it exists and headers don't already have it
|
|
493
|
-
// But only if this isn't a tuple response (where headers are explicitly controlled)
|
|
494
|
-
if (!headers["content-type"] && routeConfig.contentType && !tupleFormat) {
|
|
495
|
-
headers["content-type"] = routeConfig.contentType;
|
|
496
|
-
// Handle special conversion cases when contentType is explicitly set
|
|
497
|
-
if (routeConfig.contentType === "text/plain" && body !== undefined) {
|
|
498
|
-
if (typeof body === "object" && !Buffer.isBuffer(body)) {
|
|
499
|
-
body = JSON.stringify(body);
|
|
500
|
-
}
|
|
501
|
-
else if (typeof body !== "string") {
|
|
502
|
-
body = String(body);
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
return {
|
|
507
|
-
status,
|
|
508
|
-
body,
|
|
509
|
-
headers,
|
|
510
|
-
};
|
|
511
|
-
}
|
|
512
|
-
/**
|
|
513
|
-
* Run all registered plugins in sequence
|
|
514
|
-
* First plugin to set response becomes generator, subsequent plugins transform
|
|
515
|
-
* Handles plugin errors via onError hooks
|
|
516
|
-
* @param context - Plugin context with request details
|
|
517
|
-
* @param initialResponse - Initial response from route generator
|
|
518
|
-
* @param _routeConfig - Route config (unused but kept for signature)
|
|
519
|
-
* @param _requestId - Request ID (unused but kept for signature)
|
|
520
|
-
* @returns Updated context and final response after all plugins
|
|
521
|
-
* @private
|
|
522
|
-
*/
|
|
523
|
-
async runPluginPipeline(context, initialResponse, _routeConfig, _requestId) {
|
|
524
|
-
let currentContext = context;
|
|
525
|
-
let response = initialResponse;
|
|
526
|
-
this.logger.log("pipeline", `Running plugin pipeline for ${this.plugins.length} plugins`);
|
|
527
|
-
for (const plugin of this.plugins) {
|
|
528
|
-
this.logger.log("pipeline", `Processing plugin: ${plugin.name}`);
|
|
529
|
-
try {
|
|
530
|
-
const result = await plugin.process(currentContext, response);
|
|
531
|
-
if (!result || !result.context) {
|
|
532
|
-
throw new Error(`Plugin ${plugin.name} didn't return valid result`);
|
|
533
|
-
}
|
|
534
|
-
currentContext = result.context;
|
|
535
|
-
// First plugin to set response becomes the generator
|
|
536
|
-
if (result.response !== undefined &&
|
|
537
|
-
(response === undefined || response === null)) {
|
|
538
|
-
this.logger.log("pipeline", `Plugin ${plugin.name} generated response`);
|
|
539
|
-
response = result.response;
|
|
540
|
-
}
|
|
541
|
-
else if (result.response !== undefined && response !== undefined) {
|
|
542
|
-
this.logger.log("pipeline", `Plugin ${plugin.name} transformed response`);
|
|
543
|
-
response = result.response;
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
catch (error) {
|
|
547
|
-
this.logger.log("pipeline", `Plugin ${plugin.name} failed: ${errorMessage(error)}`);
|
|
548
|
-
// Try error handling if plugin has onError hook
|
|
549
|
-
if (plugin.onError) {
|
|
550
|
-
try {
|
|
551
|
-
const pluginError = error instanceof Error ? error : new Error(errorMessage(error));
|
|
552
|
-
const errorResult = await plugin.onError(pluginError, currentContext);
|
|
553
|
-
if (errorResult) {
|
|
554
|
-
this.logger.log("pipeline", `Plugin ${plugin.name} handled error`);
|
|
555
|
-
// Error return → transform the thrown error
|
|
556
|
-
if (errorResult instanceof Error) {
|
|
557
|
-
throw new PluginError(plugin.name, errorResult);
|
|
558
|
-
}
|
|
559
|
-
// ResponseResult return → recover, stop pipeline
|
|
560
|
-
if (typeof errorResult === "object" &&
|
|
561
|
-
errorResult !== null &&
|
|
562
|
-
"status" in errorResult) {
|
|
563
|
-
response = errorResult;
|
|
564
|
-
break;
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
// void/falsy return → propagate original error below
|
|
568
|
-
}
|
|
569
|
-
catch (hookError) {
|
|
570
|
-
// If the hook itself threw (including our PluginError above), re-throw it
|
|
571
|
-
if (hookError instanceof PluginError) {
|
|
572
|
-
throw hookError;
|
|
573
|
-
}
|
|
574
|
-
this.logger.log("pipeline", `Plugin ${plugin.name} error handler failed: ${errorMessage(hookError)}`);
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
const cause = error instanceof Error ? error : new Error(errorMessage(error));
|
|
578
|
-
throw new PluginError(plugin.name, cause);
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
return { context: currentContext, response };
|
|
582
|
-
}
|
|
583
|
-
/**
|
|
584
|
-
* Find a route that matches the given method and path
|
|
585
|
-
* Uses two-pass matching: static routes first, then parameterized routes
|
|
586
|
-
* Matches routes in registration order (first registered wins)
|
|
587
|
-
* @param method - HTTP method to match
|
|
588
|
-
* @param path - Request path to match
|
|
589
|
-
* @returns Matched compiled route or undefined if no match
|
|
590
|
-
* @private
|
|
591
|
-
*/
|
|
592
|
-
findRoute(method, path) {
|
|
593
|
-
// O(1) lookup for static routes
|
|
594
|
-
const normalizedPath = path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
|
|
595
|
-
const staticMatch = this.staticRoutes.get(`${method} ${normalizedPath}`);
|
|
596
|
-
if (staticMatch) {
|
|
597
|
-
return staticMatch;
|
|
598
|
-
}
|
|
599
|
-
// Fall through to parameterized route scan
|
|
600
|
-
for (const route of this.routes) {
|
|
601
|
-
if (route.method === method &&
|
|
602
|
-
route.params.length > 0 &&
|
|
603
|
-
route.pattern.test(path)) {
|
|
604
|
-
return route;
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
return undefined;
|
|
608
|
-
}
|
|
609
|
-
/**
|
|
610
|
-
* Extract parameter values from path based on route pattern
|
|
611
|
-
* Maps capture groups from regex match to parameter names
|
|
612
|
-
* @param route - Compiled route with pattern and param names
|
|
613
|
-
* @param path - Request path to extract values from
|
|
614
|
-
* @returns Object mapping parameter names to extracted values
|
|
615
|
-
* @private
|
|
616
|
-
*/
|
|
617
|
-
extractParams(route, path) {
|
|
618
|
-
const match = path.match(route.pattern);
|
|
619
|
-
if (!match)
|
|
620
|
-
return {};
|
|
621
|
-
const params = {};
|
|
622
|
-
route.params.forEach((param, index) => {
|
|
623
|
-
params[param] = match[index + 1];
|
|
624
|
-
});
|
|
625
|
-
return params;
|
|
626
|
-
}
|
|
627
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
|
@@ -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
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
|
*
|
|
@@ -451,7 +424,12 @@ export class CallableMockInstance {
|
|
|
451
424
|
}
|
|
452
425
|
|
|
453
426
|
// Find matching route
|
|
454
|
-
const matchedRoute =
|
|
427
|
+
const matchedRoute = findRoute(
|
|
428
|
+
method,
|
|
429
|
+
requestPath,
|
|
430
|
+
this.staticRoutes,
|
|
431
|
+
this.routes,
|
|
432
|
+
);
|
|
455
433
|
|
|
456
434
|
if (!matchedRoute) {
|
|
457
435
|
this.logger.log(
|
|
@@ -481,7 +459,7 @@ export class CallableMockInstance {
|
|
|
481
459
|
);
|
|
482
460
|
|
|
483
461
|
// Extract parameters from the matched route
|
|
484
|
-
const params =
|
|
462
|
+
const params = extractParams(matchedRoute, requestPath);
|
|
485
463
|
|
|
486
464
|
this.emit("request:match", {
|
|
487
465
|
method,
|
|
@@ -523,11 +501,11 @@ export class CallableMockInstance {
|
|
|
523
501
|
|
|
524
502
|
// Run plugin pipeline to transform the response
|
|
525
503
|
try {
|
|
526
|
-
const pipelineResult = await
|
|
504
|
+
const pipelineResult = await runPluginPipeline(
|
|
505
|
+
this.plugins,
|
|
527
506
|
pluginContext,
|
|
528
507
|
result,
|
|
529
|
-
|
|
530
|
-
requestId,
|
|
508
|
+
this.logger,
|
|
531
509
|
);
|
|
532
510
|
pluginContext = pipelineResult.context;
|
|
533
511
|
result = pipelineResult.response;
|
|
@@ -540,7 +518,7 @@ export class CallableMockInstance {
|
|
|
540
518
|
}
|
|
541
519
|
|
|
542
520
|
// Parse and prepare response
|
|
543
|
-
const response =
|
|
521
|
+
const response = parseResponse(result, matchedRoute.config);
|
|
544
522
|
|
|
545
523
|
// Apply delay (route-level overrides global)
|
|
546
524
|
await this.applyDelay(matchedRoute.config.delay);
|
|
@@ -623,238 +601,4 @@ export class CallableMockInstance {
|
|
|
623
601
|
|
|
624
602
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
625
603
|
}
|
|
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
604
|
}
|
package/src/errors.ts
CHANGED
|
@@ -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
|
+
}
|