@schmock/core 1.9.0 → 1.9.2

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