@schmock/core 1.9.0 → 1.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ import { normalizePath } from "./constants.js";
2
+
3
+ /**
4
+ * Compiled callable route with pattern matching
5
+ */
6
+ export interface CompiledCallableRoute {
7
+ pattern: RegExp;
8
+ params: string[];
9
+ method: Schmock.HttpMethod;
10
+ path: string;
11
+ generator: Schmock.Generator;
12
+ config: Schmock.RouteConfig;
13
+ }
14
+
15
+ export function isGeneratorFunction(
16
+ gen: Schmock.Generator,
17
+ ): gen is Schmock.GeneratorFunction {
18
+ return typeof gen === "function";
19
+ }
20
+
21
+ /**
22
+ * Find a route that matches the given method and path
23
+ * Uses two-pass matching: static routes first, then parameterized routes
24
+ * Matches routes in registration order (first registered wins)
25
+ */
26
+ export function findRoute(
27
+ method: Schmock.HttpMethod,
28
+ path: string,
29
+ staticRoutes: Map<string, CompiledCallableRoute>,
30
+ routes: CompiledCallableRoute[],
31
+ ): CompiledCallableRoute | undefined {
32
+ // O(1) lookup for static routes
33
+ const staticMatch = staticRoutes.get(`${method} ${normalizePath(path)}`);
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
+ }