@schmock/core 1.0.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.
Files changed (48) hide show
  1. package/dist/builder.d.ts +62 -0
  2. package/dist/builder.d.ts.map +1 -0
  3. package/dist/builder.js +432 -0
  4. package/dist/errors.d.ts +56 -0
  5. package/dist/errors.d.ts.map +1 -0
  6. package/dist/errors.js +92 -0
  7. package/dist/index.d.ts +27 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +1 -0
  10. package/dist/parser.d.ts +19 -0
  11. package/dist/parser.d.ts.map +1 -0
  12. package/dist/parser.js +40 -0
  13. package/dist/types.d.ts +15 -0
  14. package/dist/types.d.ts.map +1 -0
  15. package/dist/types.js +2 -0
  16. package/package.json +39 -0
  17. package/src/builder.d.ts.map +1 -0
  18. package/src/builder.test.ts +289 -0
  19. package/src/builder.ts +580 -0
  20. package/src/debug.test.ts +241 -0
  21. package/src/delay.test.ts +319 -0
  22. package/src/errors.d.ts.map +1 -0
  23. package/src/errors.test.ts +223 -0
  24. package/src/errors.ts +124 -0
  25. package/src/factory.test.ts +133 -0
  26. package/src/index.d.ts.map +1 -0
  27. package/src/index.ts +80 -0
  28. package/src/namespace.test.ts +273 -0
  29. package/src/parser.d.ts.map +1 -0
  30. package/src/parser.test.ts +131 -0
  31. package/src/parser.ts +61 -0
  32. package/src/plugin-system.test.ts +511 -0
  33. package/src/response-parsing.test.ts +255 -0
  34. package/src/route-matching.test.ts +351 -0
  35. package/src/smart-defaults.test.ts +361 -0
  36. package/src/steps/async-support.steps.ts +427 -0
  37. package/src/steps/basic-usage.steps.ts +316 -0
  38. package/src/steps/developer-experience.steps.ts +439 -0
  39. package/src/steps/error-handling.steps.ts +387 -0
  40. package/src/steps/fluent-api.steps.ts +252 -0
  41. package/src/steps/http-methods.steps.ts +397 -0
  42. package/src/steps/performance-reliability.steps.ts +459 -0
  43. package/src/steps/plugin-integration.steps.ts +279 -0
  44. package/src/steps/route-key-format.steps.ts +118 -0
  45. package/src/steps/state-concurrency.steps.ts +643 -0
  46. package/src/steps/stateful-workflows.steps.ts +351 -0
  47. package/src/types.d.ts.map +1 -0
  48. package/src/types.ts +17 -0
package/src/builder.ts ADDED
@@ -0,0 +1,580 @@
1
+ import {
2
+ PluginError,
3
+ RouteDefinitionError,
4
+ RouteNotFoundError,
5
+ SchmockError,
6
+ } from "./errors";
7
+ import { parseRouteKey } from "./parser";
8
+
9
+ /**
10
+ * Debug logger that respects debug mode configuration
11
+ */
12
+ class DebugLogger {
13
+ constructor(private enabled = false) {}
14
+
15
+ log(category: string, message: string, data?: any) {
16
+ if (!this.enabled) return;
17
+
18
+ const timestamp = new Date().toISOString();
19
+ const prefix = `[${timestamp}] [SCHMOCK:${category.toUpperCase()}]`;
20
+
21
+ if (data) {
22
+ console.log(`${prefix} ${message}`, data);
23
+ } else {
24
+ console.log(`${prefix} ${message}`);
25
+ }
26
+ }
27
+
28
+ time(label: string) {
29
+ if (!this.enabled) return;
30
+ console.time(`[SCHMOCK] ${label}`);
31
+ }
32
+
33
+ timeEnd(label: string) {
34
+ if (!this.enabled) return;
35
+ console.timeEnd(`[SCHMOCK] ${label}`);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Compiled callable route with pattern matching
41
+ */
42
+ interface CompiledCallableRoute {
43
+ pattern: RegExp;
44
+ params: string[];
45
+ method: Schmock.HttpMethod;
46
+ path: string;
47
+ generator: Schmock.Generator;
48
+ config: Schmock.RouteConfig;
49
+ }
50
+
51
+ /**
52
+ * Callable mock instance that implements the new API.
53
+ *
54
+ * @internal
55
+ */
56
+ export class CallableMockInstance {
57
+ private routes: CompiledCallableRoute[] = [];
58
+ private plugins: Schmock.Plugin[] = [];
59
+ private logger: DebugLogger;
60
+
61
+ constructor(private globalConfig: Schmock.GlobalConfig = {}) {
62
+ this.logger = new DebugLogger(globalConfig.debug || false);
63
+ if (globalConfig.debug) {
64
+ this.logger.log("config", "Debug mode enabled");
65
+ }
66
+ this.logger.log("config", "Callable mock instance created", {
67
+ debug: globalConfig.debug,
68
+ namespace: globalConfig.namespace,
69
+ delay: globalConfig.delay,
70
+ });
71
+ }
72
+
73
+ // Method for defining routes (called when instance is invoked)
74
+ defineRoute(
75
+ route: Schmock.RouteKey,
76
+ generator: Schmock.Generator,
77
+ config: Schmock.RouteConfig,
78
+ ): this {
79
+ // Auto-detect contentType if not provided
80
+ if (!config.contentType) {
81
+ if (typeof generator === "function") {
82
+ // Default to JSON for function generators
83
+ config.contentType = "application/json";
84
+ } else if (
85
+ typeof generator === "string" ||
86
+ typeof generator === "number" ||
87
+ typeof generator === "boolean"
88
+ ) {
89
+ // Default to plain text for primitives
90
+ config.contentType = "text/plain";
91
+ } else if (Buffer.isBuffer(generator)) {
92
+ // Default to octet-stream for buffers
93
+ config.contentType = "application/octet-stream";
94
+ } else {
95
+ // Default to JSON for objects/arrays
96
+ config.contentType = "application/json";
97
+ }
98
+ }
99
+
100
+ // Validate generator matches contentType if it's static data
101
+ if (
102
+ typeof generator !== "function" &&
103
+ config.contentType === "application/json"
104
+ ) {
105
+ try {
106
+ JSON.stringify(generator);
107
+ } catch (_error) {
108
+ throw new RouteDefinitionError(
109
+ route,
110
+ "Generator data is not valid JSON but contentType is application/json",
111
+ );
112
+ }
113
+ }
114
+
115
+ // Parse the route key to create pattern and extract parameters
116
+ const parsed = parseRouteKey(route);
117
+
118
+ // Compile the route
119
+ const compiledRoute: CompiledCallableRoute = {
120
+ pattern: parsed.pattern,
121
+ params: parsed.params,
122
+ method: parsed.method,
123
+ path: parsed.path,
124
+ generator,
125
+ config,
126
+ };
127
+
128
+ this.routes.push(compiledRoute);
129
+ this.logger.log("route", `Route defined: ${route}`, {
130
+ contentType: config.contentType,
131
+ generatorType: typeof generator,
132
+ hasParams: parsed.params.length > 0,
133
+ });
134
+
135
+ return this;
136
+ }
137
+
138
+ pipe(plugin: Schmock.Plugin): this {
139
+ this.plugins.push(plugin);
140
+ this.logger.log(
141
+ "plugin",
142
+ `Registered plugin: ${plugin.name}@${plugin.version || "unknown"}`,
143
+ {
144
+ name: plugin.name,
145
+ version: plugin.version,
146
+ hasProcess: typeof plugin.process === "function",
147
+ hasOnError: typeof plugin.onError === "function",
148
+ },
149
+ );
150
+ return this;
151
+ }
152
+
153
+ async handle(
154
+ method: Schmock.HttpMethod,
155
+ path: string,
156
+ options?: Schmock.RequestOptions,
157
+ ): Promise<Schmock.Response> {
158
+ const requestId = Math.random().toString(36).substring(7);
159
+ this.logger.log("request", `[${requestId}] ${method} ${path}`, {
160
+ headers: options?.headers,
161
+ query: options?.query,
162
+ bodyType: options?.body ? typeof options.body : "none",
163
+ });
164
+ this.logger.time(`request-${requestId}`);
165
+
166
+ try {
167
+ // Apply namespace if configured
168
+ let requestPath = path;
169
+ if (this.globalConfig.namespace) {
170
+ // Normalize namespace to handle edge cases
171
+ const namespace = this.globalConfig.namespace;
172
+ if (namespace === "/") {
173
+ // Root namespace means no transformation needed
174
+ requestPath = path;
175
+ } else {
176
+ // Handle namespace without leading slash by normalizing both namespace and path
177
+ const normalizedNamespace = namespace.startsWith("/")
178
+ ? namespace
179
+ : `/${namespace}`;
180
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
181
+
182
+ // Remove trailing slash from namespace unless it's root
183
+ const finalNamespace =
184
+ normalizedNamespace.endsWith("/") && normalizedNamespace !== "/"
185
+ ? normalizedNamespace.slice(0, -1)
186
+ : normalizedNamespace;
187
+
188
+ if (!normalizedPath.startsWith(finalNamespace)) {
189
+ this.logger.log(
190
+ "route",
191
+ `[${requestId}] Path doesn't match namespace ${namespace}`,
192
+ );
193
+ const error = new RouteNotFoundError(method, path);
194
+ const response = {
195
+ status: 404,
196
+ body: { error: error.message, code: error.code },
197
+ headers: {},
198
+ };
199
+ this.logger.timeEnd(`request-${requestId}`);
200
+ return response;
201
+ }
202
+
203
+ // Remove namespace prefix, ensuring we always start with /
204
+ requestPath = normalizedPath.substring(finalNamespace.length);
205
+ if (!requestPath.startsWith("/")) {
206
+ requestPath = `/${requestPath}`;
207
+ }
208
+ }
209
+ }
210
+
211
+ // Find matching route
212
+ const matchedRoute = this.findRoute(method, requestPath);
213
+
214
+ if (!matchedRoute) {
215
+ this.logger.log(
216
+ "route",
217
+ `[${requestId}] No route found for ${method} ${requestPath}`,
218
+ );
219
+ const error = new RouteNotFoundError(method, path);
220
+ const response = {
221
+ status: 404,
222
+ body: { error: error.message, code: error.code },
223
+ headers: {},
224
+ };
225
+ this.logger.timeEnd(`request-${requestId}`);
226
+ return response;
227
+ }
228
+
229
+ this.logger.log(
230
+ "route",
231
+ `[${requestId}] Matched route: ${method} ${matchedRoute.path}`,
232
+ );
233
+
234
+ // Extract parameters from the matched route
235
+ const params = this.extractParams(matchedRoute, requestPath);
236
+
237
+ // Generate initial response from route handler
238
+ const context: Schmock.RequestContext = {
239
+ method,
240
+ path: requestPath,
241
+ params,
242
+ query: options?.query || {},
243
+ headers: options?.headers || {},
244
+ body: options?.body,
245
+ state: this.globalConfig.state || {},
246
+ };
247
+
248
+ let result: any;
249
+ if (typeof matchedRoute.generator === "function") {
250
+ result = await (matchedRoute.generator as Schmock.GeneratorFunction)(
251
+ context,
252
+ );
253
+ } else {
254
+ result = matchedRoute.generator;
255
+ }
256
+
257
+ // Build plugin context
258
+ let pluginContext: Schmock.PluginContext = {
259
+ path: requestPath,
260
+ route: matchedRoute.config,
261
+ method,
262
+ params,
263
+ query: options?.query || {},
264
+ headers: options?.headers || {},
265
+ body: options?.body,
266
+ state: new Map(),
267
+ routeState: this.globalConfig.state || {},
268
+ };
269
+
270
+ // Run plugin pipeline to transform the response
271
+ try {
272
+ const pipelineResult = await this.runPluginPipeline(
273
+ pluginContext,
274
+ result,
275
+ matchedRoute.config,
276
+ requestId,
277
+ );
278
+ pluginContext = pipelineResult.context;
279
+ result = pipelineResult.response;
280
+ } catch (error) {
281
+ this.logger.log(
282
+ "error",
283
+ `[${requestId}] Plugin pipeline error: ${(error as Error).message}`,
284
+ );
285
+ throw error;
286
+ }
287
+
288
+ // Parse and prepare response
289
+ const response = this.parseResponse(result, matchedRoute.config);
290
+
291
+ // Apply global delay if configured
292
+ await this.applyDelay();
293
+
294
+ // Log successful response
295
+ this.logger.log(
296
+ "response",
297
+ `[${requestId}] Sending response ${response.status}`,
298
+ {
299
+ status: response.status,
300
+ headers: response.headers,
301
+ bodyType: typeof response.body,
302
+ },
303
+ );
304
+ this.logger.timeEnd(`request-${requestId}`);
305
+
306
+ return response;
307
+ } catch (error) {
308
+ this.logger.log(
309
+ "error",
310
+ `[${requestId}] Error processing request: ${(error as Error).message}`,
311
+ error,
312
+ );
313
+
314
+ // Return error response
315
+ const errorResponse = {
316
+ status: 500,
317
+ body: {
318
+ error: (error as Error).message,
319
+ code:
320
+ error instanceof SchmockError
321
+ ? (error as SchmockError).code
322
+ : "INTERNAL_ERROR",
323
+ },
324
+ headers: {},
325
+ };
326
+
327
+ // Apply global delay if configured (even for error responses)
328
+ await this.applyDelay();
329
+
330
+ this.logger.log("error", `[${requestId}] Returning error response 500`);
331
+ this.logger.timeEnd(`request-${requestId}`);
332
+ return errorResponse;
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Apply configured response delay
338
+ * Supports both fixed delays and random delays within a range
339
+ * @private
340
+ */
341
+ private async applyDelay(): Promise<void> {
342
+ if (!this.globalConfig.delay) {
343
+ return;
344
+ }
345
+
346
+ const delay = Array.isArray(this.globalConfig.delay)
347
+ ? Math.random() *
348
+ (this.globalConfig.delay[1] - this.globalConfig.delay[0]) +
349
+ this.globalConfig.delay[0]
350
+ : this.globalConfig.delay;
351
+
352
+ await new Promise((resolve) => setTimeout(resolve, delay));
353
+ }
354
+
355
+ /**
356
+ * Parse and normalize response result into Response object
357
+ * Handles tuple format [status, body, headers], direct values, and response objects
358
+ * @param result - Raw result from generator or plugin
359
+ * @param routeConfig - Route configuration for content-type defaults
360
+ * @returns Normalized Response object with status, body, and headers
361
+ * @private
362
+ */
363
+ private parseResponse(
364
+ result: any,
365
+ routeConfig: Schmock.RouteConfig,
366
+ ): Schmock.Response {
367
+ let status = 200;
368
+ let body = result;
369
+ let headers: Record<string, string> = {};
370
+
371
+ let tupleFormat = false;
372
+
373
+ // Handle already-formed response objects (from plugin error recovery)
374
+ if (
375
+ result &&
376
+ typeof result === "object" &&
377
+ "status" in result &&
378
+ "body" in result
379
+ ) {
380
+ return {
381
+ status: result.status,
382
+ body: result.body,
383
+ headers: result.headers || {},
384
+ };
385
+ }
386
+
387
+ // Handle tuple response format [status, body, headers?]
388
+ if (Array.isArray(result) && typeof result[0] === "number") {
389
+ [status, body, headers = {}] = result;
390
+ tupleFormat = true;
391
+ }
392
+
393
+ // Handle null/undefined responses with 204 No Content
394
+ // But don't auto-convert if tuple format was used (status was explicitly provided)
395
+ if (body === null || body === undefined) {
396
+ if (!tupleFormat) {
397
+ status = status === 200 ? 204 : status; // Only change to 204 if status wasn't explicitly set via tuple
398
+ }
399
+ body = undefined; // Ensure body is undefined for null responses
400
+ }
401
+
402
+ // Add content-type header from route config if it exists and headers don't already have it
403
+ // But only if this isn't a tuple response (where headers are explicitly controlled)
404
+ if (!headers["content-type"] && routeConfig.contentType && !tupleFormat) {
405
+ headers["content-type"] = routeConfig.contentType;
406
+
407
+ // Handle special conversion cases when contentType is explicitly set
408
+ if (routeConfig.contentType === "text/plain" && body !== undefined) {
409
+ if (typeof body === "object" && !Buffer.isBuffer(body)) {
410
+ body = JSON.stringify(body);
411
+ } else if (typeof body !== "string") {
412
+ body = String(body);
413
+ }
414
+ }
415
+ }
416
+
417
+ return {
418
+ status,
419
+ body,
420
+ headers,
421
+ };
422
+ }
423
+
424
+ /**
425
+ * Run all registered plugins in sequence
426
+ * First plugin to set response becomes generator, subsequent plugins transform
427
+ * Handles plugin errors via onError hooks
428
+ * @param context - Plugin context with request details
429
+ * @param initialResponse - Initial response from route generator
430
+ * @param _routeConfig - Route config (unused but kept for signature)
431
+ * @param _requestId - Request ID (unused but kept for signature)
432
+ * @returns Updated context and final response after all plugins
433
+ * @private
434
+ */
435
+ private async runPluginPipeline(
436
+ context: Schmock.PluginContext,
437
+ initialResponse?: any,
438
+ _routeConfig?: Schmock.RouteConfig,
439
+ _requestId?: string,
440
+ ): Promise<{ context: Schmock.PluginContext; response?: any }> {
441
+ let currentContext = context;
442
+ let response: any = initialResponse;
443
+
444
+ this.logger.log(
445
+ "pipeline",
446
+ `Running plugin pipeline for ${this.plugins.length} plugins`,
447
+ );
448
+
449
+ for (const plugin of this.plugins) {
450
+ this.logger.log("pipeline", `Processing plugin: ${plugin.name}`);
451
+
452
+ try {
453
+ const result = await plugin.process(currentContext, response);
454
+
455
+ if (!result || !result.context) {
456
+ throw new Error(`Plugin ${plugin.name} didn't return valid result`);
457
+ }
458
+
459
+ currentContext = result.context;
460
+
461
+ // First plugin to set response becomes the generator
462
+ if (
463
+ result.response !== undefined &&
464
+ (response === undefined || response === null)
465
+ ) {
466
+ this.logger.log(
467
+ "pipeline",
468
+ `Plugin ${plugin.name} generated response`,
469
+ );
470
+ response = result.response;
471
+ } else if (result.response !== undefined && response !== undefined) {
472
+ this.logger.log(
473
+ "pipeline",
474
+ `Plugin ${plugin.name} transformed response`,
475
+ );
476
+ response = result.response;
477
+ }
478
+ } catch (error) {
479
+ this.logger.log(
480
+ "pipeline",
481
+ `Plugin ${plugin.name} failed: ${(error as Error).message}`,
482
+ );
483
+
484
+ // Try error handling if plugin has onError hook
485
+ if (plugin.onError) {
486
+ try {
487
+ const errorResult = await plugin.onError(
488
+ error as Error,
489
+ currentContext,
490
+ );
491
+ if (errorResult) {
492
+ this.logger.log(
493
+ "pipeline",
494
+ `Plugin ${plugin.name} handled error`,
495
+ );
496
+ // If error handler returns response, use it and stop pipeline
497
+ if (typeof errorResult === "object" && errorResult.status) {
498
+ // Return the error response as the current response, stop pipeline
499
+ response = errorResult;
500
+ break;
501
+ }
502
+ }
503
+ } catch (hookError) {
504
+ this.logger.log(
505
+ "pipeline",
506
+ `Plugin ${plugin.name} error handler failed: ${(hookError as Error).message}`,
507
+ );
508
+ }
509
+ }
510
+
511
+ throw new PluginError(plugin.name, error as Error);
512
+ }
513
+ }
514
+
515
+ return { context: currentContext, response };
516
+ }
517
+
518
+ /**
519
+ * Find a route that matches the given method and path
520
+ * Uses two-pass matching: exact routes first, then parameterized routes
521
+ * Searches in reverse order to prefer most recently defined routes
522
+ * @param method - HTTP method to match
523
+ * @param path - Request path to match
524
+ * @returns Matched compiled route or undefined if no match
525
+ * @private
526
+ */
527
+ private findRoute(
528
+ method: Schmock.HttpMethod,
529
+ path: string,
530
+ ): CompiledCallableRoute | undefined {
531
+ // First pass: Look for exact matches (routes without parameters)
532
+ for (let i = this.routes.length - 1; i >= 0; i--) {
533
+ const route = this.routes[i];
534
+ if (
535
+ route.method === method &&
536
+ route.params.length === 0 &&
537
+ route.pattern.test(path)
538
+ ) {
539
+ return route;
540
+ }
541
+ }
542
+
543
+ // Second pass: Look for parameterized routes
544
+ for (let i = this.routes.length - 1; i >= 0; i--) {
545
+ const route = this.routes[i];
546
+ if (
547
+ route.method === method &&
548
+ route.params.length > 0 &&
549
+ route.pattern.test(path)
550
+ ) {
551
+ return route;
552
+ }
553
+ }
554
+
555
+ return undefined;
556
+ }
557
+
558
+ /**
559
+ * Extract parameter values from path based on route pattern
560
+ * Maps capture groups from regex match to parameter names
561
+ * @param route - Compiled route with pattern and param names
562
+ * @param path - Request path to extract values from
563
+ * @returns Object mapping parameter names to extracted values
564
+ * @private
565
+ */
566
+ private extractParams(
567
+ route: CompiledCallableRoute,
568
+ path: string,
569
+ ): Record<string, string> {
570
+ const match = path.match(route.pattern);
571
+ if (!match) return {};
572
+
573
+ const params: Record<string, string> = {};
574
+ route.params.forEach((param, index) => {
575
+ params[param] = match[index + 1];
576
+ });
577
+
578
+ return params;
579
+ }
580
+ }