@schmock/core 1.0.4 → 1.1.0

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
@@ -5,19 +5,10 @@ import {
5
5
  SchmockError,
6
6
  } from "./errors.js";
7
7
  import { parseRouteKey } from "./parser.js";
8
- import type {
9
- Generator,
10
- GeneratorFunction,
11
- GlobalConfig,
12
- HttpMethod,
13
- Plugin,
14
- PluginContext,
15
- RequestContext,
16
- RequestOptions,
17
- Response,
18
- RouteConfig,
19
- RouteKey,
20
- } from "./types.js";
8
+
9
+ function errorMessage(error: unknown): string {
10
+ return error instanceof Error ? error.message : "Unknown error";
11
+ }
21
12
 
22
13
  /**
23
14
  * Debug logger that respects debug mode configuration
@@ -25,7 +16,7 @@ import type {
25
16
  class DebugLogger {
26
17
  constructor(private enabled = false) {}
27
18
 
28
- log(category: string, message: string, data?: any) {
19
+ log(category: string, message: string, data?: unknown) {
29
20
  if (!this.enabled) return;
30
21
 
31
22
  const timestamp = new Date().toISOString();
@@ -55,10 +46,29 @@ class DebugLogger {
55
46
  interface CompiledCallableRoute {
56
47
  pattern: RegExp;
57
48
  params: string[];
58
- method: HttpMethod;
49
+ method: Schmock.HttpMethod;
59
50
  path: string;
60
- generator: Generator;
61
- config: RouteConfig;
51
+ generator: Schmock.Generator;
52
+ config: Schmock.RouteConfig;
53
+ }
54
+
55
+ function isGeneratorFunction(
56
+ gen: Schmock.Generator,
57
+ ): gen is Schmock.GeneratorFunction {
58
+ return typeof gen === "function";
59
+ }
60
+
61
+ function isResponseObject(value: unknown): value is {
62
+ status: number;
63
+ body: unknown;
64
+ headers?: Record<string, string>;
65
+ } {
66
+ return (
67
+ typeof value === "object" &&
68
+ value !== null &&
69
+ "status" in value &&
70
+ "body" in value
71
+ );
62
72
  }
63
73
 
64
74
  /**
@@ -68,10 +78,12 @@ interface CompiledCallableRoute {
68
78
  */
69
79
  export class CallableMockInstance {
70
80
  private routes: CompiledCallableRoute[] = [];
71
- private plugins: Plugin[] = [];
81
+ private staticRoutes = new Map<string, CompiledCallableRoute>();
82
+ private plugins: Schmock.Plugin[] = [];
72
83
  private logger: DebugLogger;
84
+ private requestHistory: Schmock.RequestRecord[] = [];
73
85
 
74
- constructor(private globalConfig: GlobalConfig = {}) {
86
+ constructor(private globalConfig: Schmock.GlobalConfig = {}) {
75
87
  this.logger = new DebugLogger(globalConfig.debug || false);
76
88
  if (globalConfig.debug) {
77
89
  this.logger.log("config", "Debug mode enabled");
@@ -85,9 +97,9 @@ export class CallableMockInstance {
85
97
 
86
98
  // Method for defining routes (called when instance is invoked)
87
99
  defineRoute(
88
- route: RouteKey,
89
- generator: Generator,
90
- config: RouteConfig,
100
+ route: Schmock.RouteKey,
101
+ generator: Schmock.Generator,
102
+ config: Schmock.RouteConfig,
91
103
  ): this {
92
104
  // Auto-detect contentType if not provided
93
105
  if (!config.contentType) {
@@ -150,6 +162,20 @@ export class CallableMockInstance {
150
162
  };
151
163
 
152
164
  this.routes.push(compiledRoute);
165
+
166
+ // Store static routes (no params) in Map for O(1) lookup
167
+ // Only store the first registration — "first registration wins" semantics
168
+ if (parsed.params.length === 0) {
169
+ const normalizedPath =
170
+ parsed.path.endsWith("/") && parsed.path !== "/"
171
+ ? parsed.path.slice(0, -1)
172
+ : parsed.path;
173
+ const key = `${parsed.method} ${normalizedPath}`;
174
+ if (!this.staticRoutes.has(key)) {
175
+ this.staticRoutes.set(key, compiledRoute);
176
+ }
177
+ }
178
+
153
179
  this.logger.log("route", `Route defined: ${route}`, {
154
180
  contentType: config.contentType,
155
181
  generatorType: typeof generator,
@@ -159,7 +185,7 @@ export class CallableMockInstance {
159
185
  return this;
160
186
  }
161
187
 
162
- pipe(plugin: Plugin): this {
188
+ pipe(plugin: Schmock.Plugin): this {
163
189
  this.plugins.push(plugin);
164
190
  this.logger.log(
165
191
  "plugin",
@@ -174,12 +200,83 @@ export class CallableMockInstance {
174
200
  return this;
175
201
  }
176
202
 
203
+ // ===== Request Spy / History API =====
204
+
205
+ history(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord[] {
206
+ if (method && path) {
207
+ return this.requestHistory.filter(
208
+ (r) => r.method === method && r.path === path,
209
+ );
210
+ }
211
+ return [...this.requestHistory];
212
+ }
213
+
214
+ called(method?: Schmock.HttpMethod, path?: string): boolean {
215
+ if (method && path) {
216
+ return this.requestHistory.some(
217
+ (r) => r.method === method && r.path === path,
218
+ );
219
+ }
220
+ return this.requestHistory.length > 0;
221
+ }
222
+
223
+ callCount(method?: Schmock.HttpMethod, path?: string): number {
224
+ if (method && path) {
225
+ return this.requestHistory.filter(
226
+ (r) => r.method === method && r.path === path,
227
+ ).length;
228
+ }
229
+ return this.requestHistory.length;
230
+ }
231
+
232
+ lastRequest(
233
+ method?: Schmock.HttpMethod,
234
+ path?: string,
235
+ ): Schmock.RequestRecord | undefined {
236
+ if (method && path) {
237
+ const filtered = this.requestHistory.filter(
238
+ (r) => r.method === method && r.path === path,
239
+ );
240
+ return filtered[filtered.length - 1];
241
+ }
242
+ return this.requestHistory[this.requestHistory.length - 1];
243
+ }
244
+
245
+ // ===== Reset / Lifecycle =====
246
+
247
+ reset(): void {
248
+ this.routes = [];
249
+ this.staticRoutes.clear();
250
+ this.plugins = [];
251
+ this.requestHistory = [];
252
+ if (this.globalConfig.state) {
253
+ for (const key of Object.keys(this.globalConfig.state)) {
254
+ delete this.globalConfig.state[key];
255
+ }
256
+ }
257
+ this.logger.log("lifecycle", "Mock fully reset");
258
+ }
259
+
260
+ resetHistory(): void {
261
+ this.requestHistory = [];
262
+ this.logger.log("lifecycle", "Request history cleared");
263
+ }
264
+
265
+ resetState(): void {
266
+ if (this.globalConfig.state) {
267
+ for (const key of Object.keys(this.globalConfig.state)) {
268
+ delete this.globalConfig.state[key];
269
+ }
270
+ }
271
+ this.logger.log("lifecycle", "State cleared");
272
+ }
273
+
177
274
  async handle(
178
- method: HttpMethod,
275
+ method: Schmock.HttpMethod,
179
276
  path: string,
180
- options?: RequestOptions,
181
- ): Promise<Response> {
182
- const requestId = Math.random().toString(36).substring(2, 10) || "00000000";
277
+ options?: Schmock.RequestOptions,
278
+ ): Promise<Schmock.Response> {
279
+ const requestId = crypto.randomUUID();
183
280
  this.logger.log("request", `[${requestId}] ${method} ${path}`, {
184
281
  headers: options?.headers,
185
282
  query: options?.query,
@@ -190,46 +287,40 @@ export class CallableMockInstance {
190
287
  try {
191
288
  // Apply namespace if configured
192
289
  let requestPath = path;
193
- if (this.globalConfig.namespace) {
194
- // Normalize namespace to handle edge cases
195
- const namespace = this.globalConfig.namespace;
196
- if (namespace === "/") {
197
- // Root namespace means no transformation needed
198
- requestPath = path;
199
- } else {
200
- // Handle namespace without leading slash by normalizing both namespace and path
201
- const normalizedNamespace = namespace.startsWith("/")
202
- ? namespace
203
- : `/${namespace}`;
204
- const normalizedPath = path.startsWith("/") ? path : `/${path}`;
205
-
206
- // Remove trailing slash from namespace unless it's root
207
- const finalNamespace =
208
- normalizedNamespace.endsWith("/") && normalizedNamespace !== "/"
209
- ? normalizedNamespace.slice(0, -1)
210
- : normalizedNamespace;
211
-
212
- if (!normalizedPath.startsWith(finalNamespace)) {
213
- this.logger.log(
214
- "route",
215
- `[${requestId}] Path doesn't match namespace ${namespace}`,
216
- );
217
- const error = new RouteNotFoundError(method, path);
218
- const response = {
219
- status: 404,
220
- body: { error: error.message, code: error.code },
221
- headers: {},
222
- };
223
- this.logger.timeEnd(`request-${requestId}`);
224
- return response;
225
- }
290
+ if (this.globalConfig.namespace && this.globalConfig.namespace !== "/") {
291
+ const namespace = this.globalConfig.namespace.startsWith("/")
292
+ ? this.globalConfig.namespace
293
+ : `/${this.globalConfig.namespace}`;
294
+
295
+ const pathToCheck = path.startsWith("/") ? path : `/${path}`;
296
+
297
+ // Check if path starts with namespace
298
+ // handle both "/api/users" (starts with /api) and "/api" (exact match)
299
+ // but NOT "/apiv2" (prefix match but wrong segment)
300
+ const isMatch =
301
+ pathToCheck === namespace ||
302
+ pathToCheck.startsWith(
303
+ namespace.endsWith("/") ? namespace : `${namespace}/`,
304
+ );
226
305
 
227
- // Remove namespace prefix, ensuring we always start with /
228
- requestPath = normalizedPath.substring(finalNamespace.length);
229
- if (!requestPath.startsWith("/")) {
230
- requestPath = `/${requestPath}`;
231
- }
306
+ if (!isMatch) {
307
+ this.logger.log(
308
+ "route",
309
+ `[${requestId}] Path doesn't match namespace ${namespace}`,
310
+ );
311
+ const error = new RouteNotFoundError(method, path);
312
+ const response = {
313
+ status: 404,
314
+ body: { error: error.message, code: error.code },
315
+ headers: {},
316
+ };
317
+ this.logger.timeEnd(`request-${requestId}`);
318
+ return response;
232
319
  }
320
+
321
+ // Remove namespace prefix, ensuring we always start with /
322
+ const stripped = pathToCheck.slice(namespace.length);
323
+ requestPath = stripped.startsWith("/") ? stripped : `/${stripped}`;
233
324
  }
234
325
 
235
326
  // Find matching route
@@ -259,7 +350,7 @@ export class CallableMockInstance {
259
350
  const params = this.extractParams(matchedRoute, requestPath);
260
351
 
261
352
  // Generate initial response from route handler
262
- const context: RequestContext = {
353
+ const context: Schmock.RequestContext = {
263
354
  method,
264
355
  path: requestPath,
265
356
  params,
@@ -269,15 +360,15 @@ export class CallableMockInstance {
269
360
  state: this.globalConfig.state || {},
270
361
  };
271
362
 
272
- let result: any;
273
- if (typeof matchedRoute.generator === "function") {
274
- result = await (matchedRoute.generator as GeneratorFunction)(context);
363
+ let result: unknown;
364
+ if (isGeneratorFunction(matchedRoute.generator)) {
365
+ result = await matchedRoute.generator(context);
275
366
  } else {
276
367
  result = matchedRoute.generator;
277
368
  }
278
369
 
279
370
  // Build plugin context
280
- let pluginContext: PluginContext = {
371
+ let pluginContext: Schmock.PluginContext = {
281
372
  path: requestPath,
282
373
  route: matchedRoute.config,
283
374
  method,
@@ -302,7 +393,7 @@ export class CallableMockInstance {
302
393
  } catch (error) {
303
394
  this.logger.log(
304
395
  "error",
305
- `[${requestId}] Plugin pipeline error: ${(error as Error).message}`,
396
+ `[${requestId}] Plugin pipeline error: ${errorMessage(error)}`,
306
397
  );
307
398
  throw error;
308
399
  }
@@ -313,6 +404,18 @@ export class CallableMockInstance {
313
404
  // Apply global delay if configured
314
405
  await this.applyDelay();
315
406
 
407
+ // Record request in history
408
+ this.requestHistory.push({
409
+ method,
410
+ path: requestPath,
411
+ params,
412
+ query: options?.query || {},
413
+ headers: options?.headers || {},
414
+ body: options?.body,
415
+ timestamp: Date.now(),
416
+ response: { status: response.status, body: response.body },
417
+ });
418
+
316
419
  // Log successful response
317
420
  this.logger.log(
318
421
  "response",
@@ -329,7 +432,7 @@ export class CallableMockInstance {
329
432
  } catch (error) {
330
433
  this.logger.log(
331
434
  "error",
332
- `[${requestId}] Error processing request: ${(error as Error).message}`,
435
+ `[${requestId}] Error processing request: ${errorMessage(error)}`,
333
436
  error,
334
437
  );
335
438
 
@@ -337,11 +440,8 @@ export class CallableMockInstance {
337
440
  const errorResponse = {
338
441
  status: 500,
339
442
  body: {
340
- error: (error as Error).message,
341
- code:
342
- error instanceof SchmockError
343
- ? (error as SchmockError).code
344
- : "INTERNAL_ERROR",
443
+ error: errorMessage(error),
444
+ code: error instanceof SchmockError ? error.code : "INTERNAL_ERROR",
345
445
  },
346
446
  headers: {},
347
447
  };
@@ -382,20 +482,18 @@ export class CallableMockInstance {
382
482
  * @returns Normalized Response object with status, body, and headers
383
483
  * @private
384
484
  */
385
- private parseResponse(result: any, routeConfig: RouteConfig): Response {
485
+ private parseResponse(
486
+ result: unknown,
487
+ routeConfig: Schmock.RouteConfig,
488
+ ): Schmock.Response {
386
489
  let status = 200;
387
- let body = result;
490
+ let body: unknown = result;
388
491
  let headers: Record<string, string> = {};
389
492
 
390
493
  let tupleFormat = false;
391
494
 
392
495
  // Handle already-formed response objects (from plugin error recovery)
393
- if (
394
- result &&
395
- typeof result === "object" &&
396
- "status" in result &&
397
- "body" in result
398
- ) {
496
+ if (isResponseObject(result)) {
399
497
  return {
400
498
  status: result.status,
401
499
  body: result.body,
@@ -452,13 +550,13 @@ export class CallableMockInstance {
452
550
  * @private
453
551
  */
454
552
  private async runPluginPipeline(
455
- context: PluginContext,
456
- initialResponse?: any,
457
- _routeConfig?: RouteConfig,
553
+ context: Schmock.PluginContext,
554
+ initialResponse?: unknown,
555
+ _routeConfig?: Schmock.RouteConfig,
458
556
  _requestId?: string,
459
- ): Promise<{ context: PluginContext; response?: any }> {
557
+ ): Promise<{ context: Schmock.PluginContext; response?: unknown }> {
460
558
  let currentContext = context;
461
- let response: any = initialResponse;
559
+ let response: unknown = initialResponse;
462
560
 
463
561
  this.logger.log(
464
562
  "pipeline",
@@ -497,14 +595,16 @@ export class CallableMockInstance {
497
595
  } catch (error) {
498
596
  this.logger.log(
499
597
  "pipeline",
500
- `Plugin ${plugin.name} failed: ${(error as Error).message}`,
598
+ `Plugin ${plugin.name} failed: ${errorMessage(error)}`,
501
599
  );
502
600
 
503
601
  // Try error handling if plugin has onError hook
504
602
  if (plugin.onError) {
505
603
  try {
604
+ const pluginError =
605
+ error instanceof Error ? error : new Error(errorMessage(error));
506
606
  const errorResult = await plugin.onError(
507
- error as Error,
607
+ pluginError,
508
608
  currentContext,
509
609
  );
510
610
  if (errorResult) {
@@ -512,26 +612,38 @@ export class CallableMockInstance {
512
612
  "pipeline",
513
613
  `Plugin ${plugin.name} handled error`,
514
614
  );
515
- // If error handler returns response, use it and stop pipeline
615
+
616
+ // Error return → transform the thrown error
617
+ if (errorResult instanceof Error) {
618
+ throw new PluginError(plugin.name, errorResult);
619
+ }
620
+
621
+ // ResponseResult return → recover, stop pipeline
516
622
  if (
517
623
  typeof errorResult === "object" &&
518
624
  errorResult !== null &&
519
625
  "status" in errorResult
520
626
  ) {
521
- // Return the error response as the current response, stop pipeline
522
627
  response = errorResult;
523
628
  break;
524
629
  }
525
630
  }
631
+ // void/falsy return → propagate original error below
526
632
  } catch (hookError) {
633
+ // If the hook itself threw (including our PluginError above), re-throw it
634
+ if (hookError instanceof PluginError) {
635
+ throw hookError;
636
+ }
527
637
  this.logger.log(
528
638
  "pipeline",
529
- `Plugin ${plugin.name} error handler failed: ${(hookError as Error).message}`,
639
+ `Plugin ${plugin.name} error handler failed: ${errorMessage(hookError)}`,
530
640
  );
531
641
  }
532
642
  }
533
643
 
534
- throw new PluginError(plugin.name, error as Error);
644
+ const cause =
645
+ error instanceof Error ? error : new Error(errorMessage(error));
646
+ throw new PluginError(plugin.name, cause);
535
647
  }
536
648
  }
537
649
 
@@ -548,21 +660,18 @@ export class CallableMockInstance {
548
660
  * @private
549
661
  */
550
662
  private findRoute(
551
- method: HttpMethod,
663
+ method: Schmock.HttpMethod,
552
664
  path: string,
553
665
  ): CompiledCallableRoute | undefined {
554
- // First pass: Look for static routes (routes without parameters)
555
- for (const route of this.routes) {
556
- if (
557
- route.method === method &&
558
- route.params.length === 0 &&
559
- route.pattern.test(path)
560
- ) {
561
- return route;
562
- }
666
+ // O(1) lookup for static routes
667
+ const normalizedPath =
668
+ path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
669
+ const staticMatch = this.staticRoutes.get(`${method} ${normalizedPath}`);
670
+ if (staticMatch) {
671
+ return staticMatch;
563
672
  }
564
673
 
565
- // Second pass: Look for parameterized routes
674
+ // Fall through to parameterized route scan
566
675
  for (const route of this.routes) {
567
676
  if (
568
677
  route.method === method &&
package/src/constants.ts CHANGED
@@ -13,7 +13,7 @@ export const HTTP_METHODS: readonly HttpMethod[] = [
13
13
  ] as const;
14
14
 
15
15
  export function isHttpMethod(method: string): method is HttpMethod {
16
- return HTTP_METHODS.includes(method as HttpMethod);
16
+ return (HTTP_METHODS as readonly string[]).includes(method);
17
17
  }
18
18
 
19
19
  export function toHttpMethod(method: string): HttpMethod {
package/src/index.ts CHANGED
@@ -1,12 +1,4 @@
1
- import { CallableMockInstance as CallableMockInstanceImpl } from "./builder.js";
2
- import type {
3
- CallableMockInstance,
4
- Generator,
5
- GlobalConfig,
6
- Plugin,
7
- RouteConfig,
8
- RouteKey,
9
- } from "./types.js";
1
+ import { CallableMockInstance } from "./builder.js";
10
2
 
11
3
  /**
12
4
  * Create a new Schmock mock instance with callable API.
@@ -31,28 +23,39 @@ import type {
31
23
  * @param config Optional global configuration
32
24
  * @returns A callable mock instance
33
25
  */
34
- export function schmock(config?: GlobalConfig): CallableMockInstance {
26
+ export function schmock(
27
+ config?: Schmock.GlobalConfig,
28
+ ): Schmock.CallableMockInstance {
35
29
  // Always use new callable API
36
- const instance = new CallableMockInstanceImpl(config || {});
30
+ const instance = new CallableMockInstance(config || {});
37
31
 
38
- // Create a callable function that wraps the instance
39
- const callableInstance = ((
40
- route: RouteKey,
41
- generator: Generator,
42
- routeConfig: RouteConfig = {},
43
- ) => {
44
- instance.defineRoute(route, generator, routeConfig);
45
- return callableInstance; // Return the callable function for chaining
46
- }) as any;
32
+ // Callable proxy: a function with attached methods
33
+ const callableInstance: Schmock.CallableMockInstance = Object.assign(
34
+ (
35
+ route: Schmock.RouteKey,
36
+ generator: Schmock.Generator,
37
+ routeConfig: Schmock.RouteConfig = {},
38
+ ) => {
39
+ instance.defineRoute(route, generator, routeConfig);
40
+ return callableInstance;
41
+ },
42
+ {
43
+ pipe: (plugin: Schmock.Plugin) => {
44
+ instance.pipe(plugin);
45
+ return callableInstance;
46
+ },
47
+ handle: instance.handle.bind(instance),
48
+ history: instance.history.bind(instance),
49
+ called: instance.called.bind(instance),
50
+ callCount: instance.callCount.bind(instance),
51
+ lastRequest: instance.lastRequest.bind(instance),
52
+ reset: instance.reset.bind(instance),
53
+ resetHistory: instance.resetHistory.bind(instance),
54
+ resetState: instance.resetState.bind(instance),
55
+ },
56
+ );
47
57
 
48
- // Manually bind all instance methods to the callable function with proper return values
49
- callableInstance.pipe = (plugin: Plugin) => {
50
- instance.pipe(plugin);
51
- return callableInstance; // Return callable function for chaining
52
- };
53
- callableInstance.handle = instance.handle.bind(instance);
54
-
55
- return callableInstance as CallableMockInstance;
58
+ return callableInstance;
56
59
  }
57
60
 
58
61
  // Re-export constants and utilities
@@ -74,7 +77,6 @@ export {
74
77
  SchemaValidationError,
75
78
  SchmockError,
76
79
  } from "./errors.js";
77
-
78
80
  // Re-export types
79
81
  export type {
80
82
  CallableMockInstance,
@@ -87,6 +89,7 @@ export type {
87
89
  PluginResult,
88
90
  RequestContext,
89
91
  RequestOptions,
92
+ RequestRecord,
90
93
  Response,
91
94
  ResponseBody,
92
95
  ResponseResult,
@@ -95,8 +95,9 @@ describe("namespace functionality", () => {
95
95
  const response2 = await mock.handle("GET", "/api//users");
96
96
 
97
97
  expect(response1.body).toBe("users");
98
- // This might not match depending on implementation
99
- expect(response2.status).toBe(404);
98
+ // New logic gracefully handles double slashes by stripping the full namespace
99
+ expect(response2.status).toBe(200);
100
+ expect(response2.body).toBe("users");
100
101
  });
101
102
 
102
103
  it("handles empty namespace", async () => {