@schmock/core 1.0.2 → 1.0.4
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 +2 -2
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +14 -9
- package/dist/constants.d.ts +6 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +20 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/types.d.ts +17 -13
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/builder.ts +22 -9
- package/src/constants.test.ts +59 -0
- package/src/constants.ts +25 -0
- package/src/errors.ts +3 -1
- package/src/index.ts +9 -0
- package/src/route-matching.test.ts +52 -9
- package/src/steps/developer-experience.steps.ts +18 -0
- package/src/steps/error-handling.steps.ts +25 -0
- package/src/types.ts +27 -15
package/dist/builder.d.ts
CHANGED
|
@@ -42,8 +42,8 @@ export declare class CallableMockInstance {
|
|
|
42
42
|
private runPluginPipeline;
|
|
43
43
|
/**
|
|
44
44
|
* Find a route that matches the given method and path
|
|
45
|
-
* Uses two-pass matching:
|
|
46
|
-
*
|
|
45
|
+
* Uses two-pass matching: static routes first, then parameterized routes
|
|
46
|
+
* Matches routes in registration order (first registered wins)
|
|
47
47
|
* @param method - HTTP method to match
|
|
48
48
|
* @param path - Request path to match
|
|
49
49
|
* @returns Matched compiled route or undefined if no match
|
package/dist/builder.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,SAAS,EAET,YAAY,EACZ,UAAU,EACV,MAAM,EAGN,cAAc,EACd,QAAQ,EACR,WAAW,EACX,QAAQ,EACT,MAAM,YAAY,CAAC;AA4CpB;;;;GAIG;AACH,qBAAa,oBAAoB;IAKnB,OAAO,CAAC,YAAY;IAJhC,OAAO,CAAC,MAAM,CAA+B;IAC7C,OAAO,CAAC,OAAO,CAAgB;IAC/B,OAAO,CAAC,MAAM,CAAc;gBAER,YAAY,GAAE,YAAiB;IAanD,WAAW,CACT,KAAK,EAAE,QAAQ,EACf,SAAS,EAAE,SAAS,EACpB,MAAM,EAAE,WAAW,GAClB,IAAI;
|
|
1
|
+
{"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,SAAS,EAET,YAAY,EACZ,UAAU,EACV,MAAM,EAGN,cAAc,EACd,QAAQ,EACR,WAAW,EACX,QAAQ,EACT,MAAM,YAAY,CAAC;AA4CpB;;;;GAIG;AACH,qBAAa,oBAAoB;IAKnB,OAAO,CAAC,YAAY;IAJhC,OAAO,CAAC,MAAM,CAA+B;IAC7C,OAAO,CAAC,OAAO,CAAgB;IAC/B,OAAO,CAAC,MAAM,CAAc;gBAER,YAAY,GAAE,YAAiB;IAanD,WAAW,CACT,KAAK,EAAE,QAAQ,EACf,SAAS,EAAE,SAAS,EACpB,MAAM,EAAE,WAAW,GAClB,IAAI;IAuEP,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAepB,MAAM,CACV,MAAM,EAAE,UAAU,EAClB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,cAAc,GACvB,OAAO,CAAC,QAAQ,CAAC;IAiLpB;;;;OAIG;YACW,UAAU;IAcxB;;;;;;;OAOG;IACH,OAAO,CAAC,aAAa;IA0DrB;;;;;;;;;;OAUG;YACW,iBAAiB;IAuF/B;;;;;;;;OAQG;IACH,OAAO,CAAC,SAAS;IA6BjB;;;;;;;OAOG;IACH,OAAO,CAAC,aAAa;CActB"}
|
package/dist/builder.js
CHANGED
|
@@ -88,6 +88,11 @@ export class CallableMockInstance {
|
|
|
88
88
|
}
|
|
89
89
|
// Parse the route key to create pattern and extract parameters
|
|
90
90
|
const parsed = parseRouteKey(route);
|
|
91
|
+
// Check for duplicate routes
|
|
92
|
+
const existing = this.routes.find((r) => r.method === parsed.method && r.path === parsed.path);
|
|
93
|
+
if (existing) {
|
|
94
|
+
this.logger.log("warning", `Duplicate route: ${route} — first registration wins`);
|
|
95
|
+
}
|
|
91
96
|
// Compile the route
|
|
92
97
|
const compiledRoute = {
|
|
93
98
|
pattern: parsed.pattern,
|
|
@@ -116,7 +121,7 @@ export class CallableMockInstance {
|
|
|
116
121
|
return this;
|
|
117
122
|
}
|
|
118
123
|
async handle(method, path, options) {
|
|
119
|
-
const requestId = Math.random().toString(36).substring(
|
|
124
|
+
const requestId = Math.random().toString(36).substring(2, 10) || "00000000";
|
|
120
125
|
this.logger.log("request", `[${requestId}] ${method} ${path}`, {
|
|
121
126
|
headers: options?.headers,
|
|
122
127
|
query: options?.query,
|
|
@@ -365,7 +370,9 @@ export class CallableMockInstance {
|
|
|
365
370
|
if (errorResult) {
|
|
366
371
|
this.logger.log("pipeline", `Plugin ${plugin.name} handled error`);
|
|
367
372
|
// If error handler returns response, use it and stop pipeline
|
|
368
|
-
if (typeof errorResult === "object" &&
|
|
373
|
+
if (typeof errorResult === "object" &&
|
|
374
|
+
errorResult !== null &&
|
|
375
|
+
"status" in errorResult) {
|
|
369
376
|
// Return the error response as the current response, stop pipeline
|
|
370
377
|
response = errorResult;
|
|
371
378
|
break;
|
|
@@ -383,17 +390,16 @@ export class CallableMockInstance {
|
|
|
383
390
|
}
|
|
384
391
|
/**
|
|
385
392
|
* Find a route that matches the given method and path
|
|
386
|
-
* Uses two-pass matching:
|
|
387
|
-
*
|
|
393
|
+
* Uses two-pass matching: static routes first, then parameterized routes
|
|
394
|
+
* Matches routes in registration order (first registered wins)
|
|
388
395
|
* @param method - HTTP method to match
|
|
389
396
|
* @param path - Request path to match
|
|
390
397
|
* @returns Matched compiled route or undefined if no match
|
|
391
398
|
* @private
|
|
392
399
|
*/
|
|
393
400
|
findRoute(method, path) {
|
|
394
|
-
// First pass: Look for
|
|
395
|
-
for (
|
|
396
|
-
const route = this.routes[i];
|
|
401
|
+
// First pass: Look for static routes (routes without parameters)
|
|
402
|
+
for (const route of this.routes) {
|
|
397
403
|
if (route.method === method &&
|
|
398
404
|
route.params.length === 0 &&
|
|
399
405
|
route.pattern.test(path)) {
|
|
@@ -401,8 +407,7 @@ export class CallableMockInstance {
|
|
|
401
407
|
}
|
|
402
408
|
}
|
|
403
409
|
// Second pass: Look for parameterized routes
|
|
404
|
-
for (
|
|
405
|
-
const route = this.routes[i];
|
|
410
|
+
for (const route of this.routes) {
|
|
406
411
|
if (route.method === method &&
|
|
407
412
|
route.params.length > 0 &&
|
|
408
413
|
route.pattern.test(path)) {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { HttpMethod } from "./types.js";
|
|
2
|
+
export declare const ROUTE_NOT_FOUND_CODE: "ROUTE_NOT_FOUND";
|
|
3
|
+
export declare const HTTP_METHODS: readonly HttpMethod[];
|
|
4
|
+
export declare function isHttpMethod(method: string): method is HttpMethod;
|
|
5
|
+
export declare function toHttpMethod(method: string): HttpMethod;
|
|
6
|
+
//# sourceMappingURL=constants.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,eAAO,MAAM,oBAAoB,EAAG,iBAA0B,CAAC;AAE/D,eAAO,MAAM,YAAY,EAAE,SAAS,UAAU,EAQpC,CAAC;AAEX,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,IAAI,UAAU,CAEjE;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAMvD"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const ROUTE_NOT_FOUND_CODE = "ROUTE_NOT_FOUND";
|
|
2
|
+
export const HTTP_METHODS = [
|
|
3
|
+
"GET",
|
|
4
|
+
"POST",
|
|
5
|
+
"PUT",
|
|
6
|
+
"DELETE",
|
|
7
|
+
"PATCH",
|
|
8
|
+
"HEAD",
|
|
9
|
+
"OPTIONS",
|
|
10
|
+
];
|
|
11
|
+
export function isHttpMethod(method) {
|
|
12
|
+
return HTTP_METHODS.includes(method);
|
|
13
|
+
}
|
|
14
|
+
export function toHttpMethod(method) {
|
|
15
|
+
const upper = method.toUpperCase();
|
|
16
|
+
if (!isHttpMethod(upper)) {
|
|
17
|
+
throw new Error(`Invalid HTTP method: "${method}"`);
|
|
18
|
+
}
|
|
19
|
+
return upper;
|
|
20
|
+
}
|
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;
|
|
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"}
|
package/dist/errors.js
CHANGED
|
@@ -9,7 +9,9 @@ export class SchmockError extends Error {
|
|
|
9
9
|
this.code = code;
|
|
10
10
|
this.context = context;
|
|
11
11
|
this.name = "SchmockError";
|
|
12
|
-
Error.captureStackTrace
|
|
12
|
+
if (typeof Error.captureStackTrace === "function") {
|
|
13
|
+
Error.captureStackTrace(this, this.constructor);
|
|
14
|
+
}
|
|
13
15
|
}
|
|
14
16
|
}
|
|
15
17
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type { CallableMockInstance, GlobalConfig } from "./types.js";
|
|
|
23
23
|
* @returns A callable mock instance
|
|
24
24
|
*/
|
|
25
25
|
export declare function schmock(config?: GlobalConfig): CallableMockInstance;
|
|
26
|
+
export { HTTP_METHODS, isHttpMethod, ROUTE_NOT_FOUND_CODE, toHttpMethod, } from "./constants.js";
|
|
26
27
|
export { PluginError, ResourceLimitError, ResponseGenerationError, RouteDefinitionError, RouteNotFoundError, RouteParseError, SchemaGenerationError, SchemaValidationError, SchmockError, } from "./errors.js";
|
|
27
|
-
export type { CallableMockInstance, Generator, GeneratorFunction, GlobalConfig, HttpMethod, Plugin, PluginContext, PluginResult, RequestContext, RequestOptions, Response, ResponseResult, RouteConfig, RouteKey, } from "./types.js";
|
|
28
|
+
export type { CallableMockInstance, Generator, GeneratorFunction, GlobalConfig, HttpMethod, Plugin, PluginContext, PluginResult, RequestContext, RequestOptions, Response, ResponseBody, ResponseResult, RouteConfig, RouteKey, StaticData, } from "./types.js";
|
|
28
29
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,oBAAoB,EAEpB,YAAY,EAIb,MAAM,YAAY,CAAC;AAEpB;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,OAAO,CAAC,MAAM,CAAC,EAAE,YAAY,GAAG,oBAAoB,CAsBnE;AAGD,OAAO,EACL,WAAW,EACX,kBAAkB,EAClB,uBAAuB,EACvB,oBAAoB,EACpB,kBAAkB,EAClB,eAAe,EACf,qBAAqB,EACrB,qBAAqB,EACrB,YAAY,GACb,MAAM,aAAa,CAAC;AAGrB,YAAY,EACV,oBAAoB,EACpB,SAAS,EACT,iBAAiB,EACjB,YAAY,EACZ,UAAU,EACV,MAAM,EACN,aAAa,EACb,YAAY,EACZ,cAAc,EACd,cAAc,EACd,QAAQ,EACR,cAAc,EACd,WAAW,EACX,QAAQ,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,oBAAoB,EAEpB,YAAY,EAIb,MAAM,YAAY,CAAC;AAEpB;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,OAAO,CAAC,MAAM,CAAC,EAAE,YAAY,GAAG,oBAAoB,CAsBnE;AAGD,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,oBAAoB,EACpB,YAAY,GACb,MAAM,gBAAgB,CAAC;AAExB,OAAO,EACL,WAAW,EACX,kBAAkB,EAClB,uBAAuB,EACvB,oBAAoB,EACpB,kBAAkB,EAClB,eAAe,EACf,qBAAqB,EACrB,qBAAqB,EACrB,YAAY,GACb,MAAM,aAAa,CAAC;AAGrB,YAAY,EACV,oBAAoB,EACpB,SAAS,EACT,iBAAiB,EACjB,YAAY,EACZ,UAAU,EACV,MAAM,EACN,aAAa,EACb,YAAY,EACZ,cAAc,EACd,cAAc,EACd,QAAQ,EACR,YAAY,EACZ,cAAc,EACd,WAAW,EACX,QAAQ,EACR,UAAU,GACX,MAAM,YAAY,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -38,5 +38,7 @@ export function schmock(config) {
|
|
|
38
38
|
callableInstance.handle = instance.handle.bind(instance);
|
|
39
39
|
return callableInstance;
|
|
40
40
|
}
|
|
41
|
+
// Re-export constants and utilities
|
|
42
|
+
export { HTTP_METHODS, isHttpMethod, ROUTE_NOT_FOUND_CODE, toHttpMethod, } from "./constants.js";
|
|
41
43
|
// Re-export errors
|
|
42
44
|
export { PluginError, ResourceLimitError, ResponseGenerationError, RouteDefinitionError, RouteNotFoundError, RouteParseError, SchemaGenerationError, SchemaValidationError, SchmockError, } from "./errors.js";
|
package/dist/types.d.ts
CHANGED
|
@@ -39,7 +39,7 @@ export interface Plugin {
|
|
|
39
39
|
* @param response - Response from previous plugin (if any)
|
|
40
40
|
* @returns Updated context and response
|
|
41
41
|
*/
|
|
42
|
-
process(context: PluginContext, response?:
|
|
42
|
+
process(context: PluginContext, response?: unknown): PluginResult | Promise<PluginResult>;
|
|
43
43
|
/**
|
|
44
44
|
* Called when an error occurs
|
|
45
45
|
* Can handle, transform, or suppress errors
|
|
@@ -56,7 +56,7 @@ export interface PluginResult {
|
|
|
56
56
|
/** Updated context */
|
|
57
57
|
context: PluginContext;
|
|
58
58
|
/** Response data (if generated/modified) */
|
|
59
|
-
response?:
|
|
59
|
+
response?: unknown;
|
|
60
60
|
}
|
|
61
61
|
/**
|
|
62
62
|
* Context passed through plugin pipeline
|
|
@@ -65,7 +65,7 @@ export interface PluginContext {
|
|
|
65
65
|
/** Request path */
|
|
66
66
|
path: string;
|
|
67
67
|
/** Matched route configuration */
|
|
68
|
-
route:
|
|
68
|
+
route: RouteConfig;
|
|
69
69
|
/** HTTP method */
|
|
70
70
|
method: HttpMethod;
|
|
71
71
|
/** Route parameters */
|
|
@@ -75,11 +75,11 @@ export interface PluginContext {
|
|
|
75
75
|
/** Request headers */
|
|
76
76
|
headers: Record<string, string>;
|
|
77
77
|
/** Request body */
|
|
78
|
-
body?:
|
|
78
|
+
body?: unknown;
|
|
79
79
|
/** Shared state between plugins for this request */
|
|
80
|
-
state: Map<string,
|
|
80
|
+
state: Map<string, unknown>;
|
|
81
81
|
/** Route-specific state */
|
|
82
|
-
routeState?:
|
|
82
|
+
routeState?: Record<string, unknown>;
|
|
83
83
|
}
|
|
84
84
|
/**
|
|
85
85
|
* Global configuration options for the mock instance
|
|
@@ -92,7 +92,7 @@ export interface GlobalConfig {
|
|
|
92
92
|
/** Enable debug mode for detailed logging */
|
|
93
93
|
debug?: boolean;
|
|
94
94
|
/** Initial shared state object */
|
|
95
|
-
state?:
|
|
95
|
+
state?: Record<string, unknown>;
|
|
96
96
|
}
|
|
97
97
|
/**
|
|
98
98
|
* Route-specific configuration options
|
|
@@ -111,10 +111,14 @@ export type Generator = GeneratorFunction | StaticData | JSONSchema;
|
|
|
111
111
|
* Function that generates responses
|
|
112
112
|
*/
|
|
113
113
|
export type GeneratorFunction = (context: RequestContext) => ResponseResult | Promise<ResponseResult>;
|
|
114
|
+
/**
|
|
115
|
+
* Response body type alias
|
|
116
|
+
*/
|
|
117
|
+
export type ResponseBody = unknown;
|
|
114
118
|
/**
|
|
115
119
|
* Static data (non-function) that gets returned as-is
|
|
116
120
|
*/
|
|
117
|
-
export type StaticData =
|
|
121
|
+
export type StaticData = string | number | boolean | null | undefined | Record<string, unknown> | unknown[];
|
|
118
122
|
/**
|
|
119
123
|
* Context passed to generator functions
|
|
120
124
|
*/
|
|
@@ -130,9 +134,9 @@ export interface RequestContext {
|
|
|
130
134
|
/** Request headers */
|
|
131
135
|
headers: Record<string, string>;
|
|
132
136
|
/** Request body (for POST, PUT, PATCH) */
|
|
133
|
-
body?:
|
|
137
|
+
body?: unknown;
|
|
134
138
|
/** Shared mutable state */
|
|
135
|
-
state:
|
|
139
|
+
state: Record<string, unknown>;
|
|
136
140
|
}
|
|
137
141
|
/**
|
|
138
142
|
* Response result types:
|
|
@@ -140,13 +144,13 @@ export interface RequestContext {
|
|
|
140
144
|
* - [status, body]: custom status with body
|
|
141
145
|
* - [status, body, headers]: custom status, body, and headers
|
|
142
146
|
*/
|
|
143
|
-
export type ResponseResult =
|
|
147
|
+
export type ResponseResult = ResponseBody | [number, ResponseBody] | [number, ResponseBody, Record<string, string>];
|
|
144
148
|
/**
|
|
145
149
|
* Response object returned by handle method
|
|
146
150
|
*/
|
|
147
151
|
export interface Response {
|
|
148
152
|
status: number;
|
|
149
|
-
body:
|
|
153
|
+
body: unknown;
|
|
150
154
|
headers: Record<string, string>;
|
|
151
155
|
}
|
|
152
156
|
/**
|
|
@@ -154,7 +158,7 @@ export interface Response {
|
|
|
154
158
|
*/
|
|
155
159
|
export interface RequestOptions {
|
|
156
160
|
headers?: Record<string, string>;
|
|
157
|
-
body?:
|
|
161
|
+
body?: unknown;
|
|
158
162
|
query?: Record<string, string>;
|
|
159
163
|
}
|
|
160
164
|
/**
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACxC,KAAK,CAAC,EAAE,UAAU,GAAG,UAAU,EAAE,CAAC;IAClC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,IAAI,CAAC,EAAE,GAAG,EAAE,CAAC;IACb,KAAK,CAAC,EAAE,GAAG,CAAC;IACZ,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,MAAM,UAAU,GAClB,KAAK,GACL,MAAM,GACN,KAAK,GACL,QAAQ,GACR,OAAO,GACP,MAAM,GACN,SAAS,CAAC;AAEd;;;;;;;GAOG;AACH,MAAM,MAAM,QAAQ,GAAG,GAAG,UAAU,IAAI,MAAM,EAAE,CAAC;AAEjD;;GAEG;AACH,MAAM,WAAW,MAAM;IACrB,+BAA+B;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,8BAA8B;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;;;;OAMG;IACH,OAAO,CACL,OAAO,EAAE,aAAa,EACtB,QAAQ,CAAC,EAAE,
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@schmock/core",
|
|
3
3
|
"description": "Core functionality for Schmock",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
},
|
|
32
32
|
"license": "MIT",
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@amiceli/vitest-cucumber": "^6.
|
|
35
|
-
"@types/node": "^
|
|
36
|
-
"@vitest/ui": "^4.0.
|
|
34
|
+
"@amiceli/vitest-cucumber": "^6.2.0",
|
|
35
|
+
"@types/node": "^25.1.0",
|
|
36
|
+
"@vitest/ui": "^4.0.16",
|
|
37
37
|
"vitest": "^4.0.15"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/builder.ts
CHANGED
|
@@ -128,6 +128,17 @@ export class CallableMockInstance {
|
|
|
128
128
|
// Parse the route key to create pattern and extract parameters
|
|
129
129
|
const parsed = parseRouteKey(route);
|
|
130
130
|
|
|
131
|
+
// Check for duplicate routes
|
|
132
|
+
const existing = this.routes.find(
|
|
133
|
+
(r) => r.method === parsed.method && r.path === parsed.path,
|
|
134
|
+
);
|
|
135
|
+
if (existing) {
|
|
136
|
+
this.logger.log(
|
|
137
|
+
"warning",
|
|
138
|
+
`Duplicate route: ${route} — first registration wins`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
131
142
|
// Compile the route
|
|
132
143
|
const compiledRoute: CompiledCallableRoute = {
|
|
133
144
|
pattern: parsed.pattern,
|
|
@@ -168,7 +179,7 @@ export class CallableMockInstance {
|
|
|
168
179
|
path: string,
|
|
169
180
|
options?: RequestOptions,
|
|
170
181
|
): Promise<Response> {
|
|
171
|
-
const requestId = Math.random().toString(36).substring(
|
|
182
|
+
const requestId = Math.random().toString(36).substring(2, 10) || "00000000";
|
|
172
183
|
this.logger.log("request", `[${requestId}] ${method} ${path}`, {
|
|
173
184
|
headers: options?.headers,
|
|
174
185
|
query: options?.query,
|
|
@@ -502,7 +513,11 @@ export class CallableMockInstance {
|
|
|
502
513
|
`Plugin ${plugin.name} handled error`,
|
|
503
514
|
);
|
|
504
515
|
// If error handler returns response, use it and stop pipeline
|
|
505
|
-
if (
|
|
516
|
+
if (
|
|
517
|
+
typeof errorResult === "object" &&
|
|
518
|
+
errorResult !== null &&
|
|
519
|
+
"status" in errorResult
|
|
520
|
+
) {
|
|
506
521
|
// Return the error response as the current response, stop pipeline
|
|
507
522
|
response = errorResult;
|
|
508
523
|
break;
|
|
@@ -525,8 +540,8 @@ export class CallableMockInstance {
|
|
|
525
540
|
|
|
526
541
|
/**
|
|
527
542
|
* Find a route that matches the given method and path
|
|
528
|
-
* Uses two-pass matching:
|
|
529
|
-
*
|
|
543
|
+
* Uses two-pass matching: static routes first, then parameterized routes
|
|
544
|
+
* Matches routes in registration order (first registered wins)
|
|
530
545
|
* @param method - HTTP method to match
|
|
531
546
|
* @param path - Request path to match
|
|
532
547
|
* @returns Matched compiled route or undefined if no match
|
|
@@ -536,9 +551,8 @@ export class CallableMockInstance {
|
|
|
536
551
|
method: HttpMethod,
|
|
537
552
|
path: string,
|
|
538
553
|
): CompiledCallableRoute | undefined {
|
|
539
|
-
// First pass: Look for
|
|
540
|
-
for (
|
|
541
|
-
const route = this.routes[i];
|
|
554
|
+
// First pass: Look for static routes (routes without parameters)
|
|
555
|
+
for (const route of this.routes) {
|
|
542
556
|
if (
|
|
543
557
|
route.method === method &&
|
|
544
558
|
route.params.length === 0 &&
|
|
@@ -549,8 +563,7 @@ export class CallableMockInstance {
|
|
|
549
563
|
}
|
|
550
564
|
|
|
551
565
|
// Second pass: Look for parameterized routes
|
|
552
|
-
for (
|
|
553
|
-
const route = this.routes[i];
|
|
566
|
+
for (const route of this.routes) {
|
|
554
567
|
if (
|
|
555
568
|
route.method === method &&
|
|
556
569
|
route.params.length > 0 &&
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
HTTP_METHODS,
|
|
4
|
+
isHttpMethod,
|
|
5
|
+
ROUTE_NOT_FOUND_CODE,
|
|
6
|
+
toHttpMethod,
|
|
7
|
+
} from "./constants";
|
|
8
|
+
|
|
9
|
+
describe("constants", () => {
|
|
10
|
+
it("exports ROUTE_NOT_FOUND_CODE", () => {
|
|
11
|
+
expect(ROUTE_NOT_FOUND_CODE).toBe("ROUTE_NOT_FOUND");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("exports all HTTP methods", () => {
|
|
15
|
+
expect(HTTP_METHODS).toEqual([
|
|
16
|
+
"GET",
|
|
17
|
+
"POST",
|
|
18
|
+
"PUT",
|
|
19
|
+
"DELETE",
|
|
20
|
+
"PATCH",
|
|
21
|
+
"HEAD",
|
|
22
|
+
"OPTIONS",
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("isHttpMethod", () => {
|
|
28
|
+
it("returns true for valid HTTP methods", () => {
|
|
29
|
+
for (const method of HTTP_METHODS) {
|
|
30
|
+
expect(isHttpMethod(method)).toBe(true);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns false for invalid methods", () => {
|
|
35
|
+
expect(isHttpMethod("INVALID")).toBe(false);
|
|
36
|
+
expect(isHttpMethod("")).toBe(false);
|
|
37
|
+
expect(isHttpMethod("get")).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("toHttpMethod", () => {
|
|
42
|
+
it("converts lowercase to uppercase", () => {
|
|
43
|
+
expect(toHttpMethod("get")).toBe("GET");
|
|
44
|
+
expect(toHttpMethod("post")).toBe("POST");
|
|
45
|
+
expect(toHttpMethod("delete")).toBe("DELETE");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns already uppercase methods", () => {
|
|
49
|
+
expect(toHttpMethod("GET")).toBe("GET");
|
|
50
|
+
expect(toHttpMethod("PATCH")).toBe("PATCH");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("throws for invalid methods", () => {
|
|
54
|
+
expect(() => toHttpMethod("INVALID")).toThrow(
|
|
55
|
+
'Invalid HTTP method: "INVALID"',
|
|
56
|
+
);
|
|
57
|
+
expect(() => toHttpMethod("")).toThrow('Invalid HTTP method: ""');
|
|
58
|
+
});
|
|
59
|
+
});
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { HttpMethod } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export const ROUTE_NOT_FOUND_CODE = "ROUTE_NOT_FOUND" as const;
|
|
4
|
+
|
|
5
|
+
export const HTTP_METHODS: readonly HttpMethod[] = [
|
|
6
|
+
"GET",
|
|
7
|
+
"POST",
|
|
8
|
+
"PUT",
|
|
9
|
+
"DELETE",
|
|
10
|
+
"PATCH",
|
|
11
|
+
"HEAD",
|
|
12
|
+
"OPTIONS",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
export function isHttpMethod(method: string): method is HttpMethod {
|
|
16
|
+
return HTTP_METHODS.includes(method as HttpMethod);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function toHttpMethod(method: string): HttpMethod {
|
|
20
|
+
const upper = method.toUpperCase();
|
|
21
|
+
if (!isHttpMethod(upper)) {
|
|
22
|
+
throw new Error(`Invalid HTTP method: "${method}"`);
|
|
23
|
+
}
|
|
24
|
+
return upper;
|
|
25
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -9,7 +9,9 @@ export class SchmockError extends Error {
|
|
|
9
9
|
) {
|
|
10
10
|
super(message);
|
|
11
11
|
this.name = "SchmockError";
|
|
12
|
-
Error.captureStackTrace
|
|
12
|
+
if (typeof Error.captureStackTrace === "function") {
|
|
13
|
+
Error.captureStackTrace(this, this.constructor);
|
|
14
|
+
}
|
|
13
15
|
}
|
|
14
16
|
}
|
|
15
17
|
|
package/src/index.ts
CHANGED
|
@@ -55,6 +55,13 @@ export function schmock(config?: GlobalConfig): CallableMockInstance {
|
|
|
55
55
|
return callableInstance as CallableMockInstance;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// Re-export constants and utilities
|
|
59
|
+
export {
|
|
60
|
+
HTTP_METHODS,
|
|
61
|
+
isHttpMethod,
|
|
62
|
+
ROUTE_NOT_FOUND_CODE,
|
|
63
|
+
toHttpMethod,
|
|
64
|
+
} from "./constants.js";
|
|
58
65
|
// Re-export errors
|
|
59
66
|
export {
|
|
60
67
|
PluginError,
|
|
@@ -81,7 +88,9 @@ export type {
|
|
|
81
88
|
RequestContext,
|
|
82
89
|
RequestOptions,
|
|
83
90
|
Response,
|
|
91
|
+
ResponseBody,
|
|
84
92
|
ResponseResult,
|
|
85
93
|
RouteConfig,
|
|
86
94
|
RouteKey,
|
|
95
|
+
StaticData,
|
|
87
96
|
} from "./types.js";
|
|
@@ -128,7 +128,7 @@ describe("route matching", () => {
|
|
|
128
128
|
});
|
|
129
129
|
|
|
130
130
|
describe("route precedence and conflicts", () => {
|
|
131
|
-
it("
|
|
131
|
+
it("prioritizes static routes over parameterized routes", async () => {
|
|
132
132
|
const mock = schmock();
|
|
133
133
|
mock("GET /users/:id", "parameterized");
|
|
134
134
|
mock("GET /users/special", "static");
|
|
@@ -136,7 +136,7 @@ describe("route matching", () => {
|
|
|
136
136
|
const paramResponse = await mock.handle("GET", "/users/123");
|
|
137
137
|
const staticResponse = await mock.handle("GET", "/users/special");
|
|
138
138
|
|
|
139
|
-
//
|
|
139
|
+
// Static routes should always be checked before parameterized routes
|
|
140
140
|
expect(paramResponse.body).toBe("parameterized");
|
|
141
141
|
expect(staticResponse.body).toBe("static");
|
|
142
142
|
});
|
|
@@ -153,18 +153,61 @@ describe("route matching", () => {
|
|
|
153
153
|
expect(v1Response.body).toBe("v1-specific");
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
-
it("matches
|
|
156
|
+
it("matches routes in registration order (first registered wins)", async () => {
|
|
157
157
|
const mock = schmock();
|
|
158
158
|
mock("GET /:type/items", "first");
|
|
159
159
|
mock("GET /shop/:category", "second");
|
|
160
160
|
|
|
161
161
|
const response = await mock.handle("GET", "/shop/items");
|
|
162
162
|
|
|
163
|
-
// Both routes match, but
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
163
|
+
// Both routes match, but the first registered route should win
|
|
164
|
+
// This matches the behavior of Express, Hono, Fastify, etc.
|
|
165
|
+
expect(response.body).toBe("first");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("matches specific routes before wildcard when registered in natural order", async () => {
|
|
169
|
+
// Bug report reproduction: natural order (specific before wildcard)
|
|
170
|
+
const mock = schmock();
|
|
171
|
+
mock("GET /api/items/special", () => ({ type: "special" }));
|
|
172
|
+
mock("GET /api/items/:id", () => ({ type: "generic" }));
|
|
173
|
+
|
|
174
|
+
const specialResult = await mock.handle("GET", "/api/items/special");
|
|
175
|
+
const genericResult = await mock.handle("GET", "/api/items/123");
|
|
176
|
+
|
|
177
|
+
// Static route should match for /api/items/special
|
|
178
|
+
expect(specialResult.body).toEqual({ type: "special" });
|
|
179
|
+
// Parameterized route should match for /api/items/123
|
|
180
|
+
expect(genericResult.body).toEqual({ type: "generic" });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("matches multiple specific routes before wildcard", async () => {
|
|
184
|
+
// Bug report scenario with multiple specific routes
|
|
185
|
+
const mock = schmock();
|
|
186
|
+
mock("GET /api/vulns/aggregated", "aggregated");
|
|
187
|
+
mock("GET /api/vulns/count", "count");
|
|
188
|
+
mock("GET /api/vulns/familyList", "familyList");
|
|
189
|
+
mock("GET /api/vulns/:vulnId", "byId");
|
|
190
|
+
|
|
191
|
+
const aggregatedRes = await mock.handle("GET", "/api/vulns/aggregated");
|
|
192
|
+
const countRes = await mock.handle("GET", "/api/vulns/count");
|
|
193
|
+
const familyListRes = await mock.handle("GET", "/api/vulns/familyList");
|
|
194
|
+
const byIdRes = await mock.handle("GET", "/api/vulns/CVE-2024-1234");
|
|
195
|
+
|
|
196
|
+
expect(aggregatedRes.body).toBe("aggregated");
|
|
197
|
+
expect(countRes.body).toBe("count");
|
|
198
|
+
expect(familyListRes.body).toBe("familyList");
|
|
199
|
+
expect(byIdRes.body).toBe("byId");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("matches overlapping parameterized routes in registration order", async () => {
|
|
203
|
+
const mock = schmock();
|
|
204
|
+
mock("GET /api/:org/users/:id", "first-pattern");
|
|
205
|
+
mock("GET /api/:version/users/:userId", "second-pattern");
|
|
206
|
+
|
|
207
|
+
const response = await mock.handle("GET", "/api/acme/users/123");
|
|
208
|
+
|
|
209
|
+
// When both routes are parameterized and match, first registered wins
|
|
210
|
+
expect(response.body).toBe("first-pattern");
|
|
168
211
|
});
|
|
169
212
|
});
|
|
170
213
|
|
|
@@ -268,7 +311,7 @@ describe("route matching", () => {
|
|
|
268
311
|
mock("GET /items/:id", ({ params }) => ({
|
|
269
312
|
id: params.id,
|
|
270
313
|
type: typeof params.id,
|
|
271
|
-
parsed: Number.parseInt(params.id),
|
|
314
|
+
parsed: Number.parseInt(params.id, 10),
|
|
272
315
|
}));
|
|
273
316
|
|
|
274
317
|
const response = await mock.handle("GET", "/items/12345");
|
|
@@ -413,6 +413,24 @@ describeFeature(feature, ({ Scenario }) => {
|
|
|
413
413
|
});
|
|
414
414
|
});
|
|
415
415
|
|
|
416
|
+
Scenario("Registering duplicate routes first route wins", ({ Given, When, Then }) => {
|
|
417
|
+
Given("I create a mock with duplicate routes:", (_, docString: string) => {
|
|
418
|
+
mock = schmock();
|
|
419
|
+
mock('GET /items', [{ id: 1 }]);
|
|
420
|
+
mock('GET /items', [{ id: 2 }]);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
When("I request {string}", async (_, request: string) => {
|
|
424
|
+
const [method, path] = request.split(" ");
|
|
425
|
+
response = await mock.handle(method as any, path);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
Then("the first route response should win:", (_, docString: string) => {
|
|
429
|
+
const expected = JSON.parse(docString);
|
|
430
|
+
expect(response.body).toEqual(expected);
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
416
434
|
Scenario("Plugin returning unexpected structure", ({ Given, When, Then, And }) => {
|
|
417
435
|
Given("I create a mock with malformed plugin:", (_, docString: string) => {
|
|
418
436
|
mock = schmock();
|
|
@@ -361,6 +361,31 @@ describeFeature(feature, ({ Scenario }) => {
|
|
|
361
361
|
});
|
|
362
362
|
});
|
|
363
363
|
|
|
364
|
+
Scenario("Plugin onError returns response with status 0", ({ Given, When, Then, And }) => {
|
|
365
|
+
Given("I create a mock with status-zero error handler:", (_, docString: string) => {
|
|
366
|
+
mock = schmock();
|
|
367
|
+
const plugin = {
|
|
368
|
+
name: 'zero-status',
|
|
369
|
+
process: () => { throw new Error('fail'); },
|
|
370
|
+
onError: () => ({ status: 0, body: 'zero status', headers: {} })
|
|
371
|
+
};
|
|
372
|
+
mock('GET /zero', 'original').pipe(plugin);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
When("I request {string}", async (_, request: string) => {
|
|
376
|
+
const [method, path] = request.split(" ");
|
|
377
|
+
response = await mock.handle(method as any, path);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
Then("I should receive status {int}", (_, status: number) => {
|
|
381
|
+
expect(response.status).toBe(status);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
And("I should receive text {string}", (_, expectedText: string) => {
|
|
385
|
+
expect(response.body).toBe(expectedText);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
364
389
|
Scenario("Plugin null/undefined return handling", ({ Given, When, Then, And }) => {
|
|
365
390
|
Given("I create a mock with null-returning plugin:", (_, docString: string) => {
|
|
366
391
|
mock = schmock();
|
package/src/types.ts
CHANGED
|
@@ -52,7 +52,7 @@ export interface Plugin {
|
|
|
52
52
|
*/
|
|
53
53
|
process(
|
|
54
54
|
context: PluginContext,
|
|
55
|
-
response?:
|
|
55
|
+
response?: unknown,
|
|
56
56
|
): PluginResult | Promise<PluginResult>;
|
|
57
57
|
|
|
58
58
|
/**
|
|
@@ -79,7 +79,7 @@ export interface PluginResult {
|
|
|
79
79
|
/** Updated context */
|
|
80
80
|
context: PluginContext;
|
|
81
81
|
/** Response data (if generated/modified) */
|
|
82
|
-
response?:
|
|
82
|
+
response?: unknown;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/**
|
|
@@ -89,7 +89,7 @@ export interface PluginContext {
|
|
|
89
89
|
/** Request path */
|
|
90
90
|
path: string;
|
|
91
91
|
/** Matched route configuration */
|
|
92
|
-
route:
|
|
92
|
+
route: RouteConfig;
|
|
93
93
|
/** HTTP method */
|
|
94
94
|
method: HttpMethod;
|
|
95
95
|
/** Route parameters */
|
|
@@ -99,11 +99,11 @@ export interface PluginContext {
|
|
|
99
99
|
/** Request headers */
|
|
100
100
|
headers: Record<string, string>;
|
|
101
101
|
/** Request body */
|
|
102
|
-
body?:
|
|
102
|
+
body?: unknown;
|
|
103
103
|
/** Shared state between plugins for this request */
|
|
104
|
-
state: Map<string,
|
|
104
|
+
state: Map<string, unknown>;
|
|
105
105
|
/** Route-specific state */
|
|
106
|
-
routeState?:
|
|
106
|
+
routeState?: Record<string, unknown>;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/**
|
|
@@ -117,7 +117,7 @@ export interface GlobalConfig {
|
|
|
117
117
|
/** Enable debug mode for detailed logging */
|
|
118
118
|
debug?: boolean;
|
|
119
119
|
/** Initial shared state object */
|
|
120
|
-
state?:
|
|
120
|
+
state?: Record<string, unknown>;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
/**
|
|
@@ -142,10 +142,22 @@ export type GeneratorFunction = (
|
|
|
142
142
|
context: RequestContext,
|
|
143
143
|
) => ResponseResult | Promise<ResponseResult>;
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Response body type alias
|
|
147
|
+
*/
|
|
148
|
+
export type ResponseBody = unknown;
|
|
149
|
+
|
|
145
150
|
/**
|
|
146
151
|
* Static data (non-function) that gets returned as-is
|
|
147
152
|
*/
|
|
148
|
-
export type StaticData =
|
|
153
|
+
export type StaticData =
|
|
154
|
+
| string
|
|
155
|
+
| number
|
|
156
|
+
| boolean
|
|
157
|
+
| null
|
|
158
|
+
| undefined
|
|
159
|
+
| Record<string, unknown>
|
|
160
|
+
| unknown[];
|
|
149
161
|
|
|
150
162
|
/**
|
|
151
163
|
* Context passed to generator functions
|
|
@@ -162,9 +174,9 @@ export interface RequestContext {
|
|
|
162
174
|
/** Request headers */
|
|
163
175
|
headers: Record<string, string>;
|
|
164
176
|
/** Request body (for POST, PUT, PATCH) */
|
|
165
|
-
body?:
|
|
177
|
+
body?: unknown;
|
|
166
178
|
/** Shared mutable state */
|
|
167
|
-
state:
|
|
179
|
+
state: Record<string, unknown>;
|
|
168
180
|
}
|
|
169
181
|
|
|
170
182
|
/**
|
|
@@ -174,16 +186,16 @@ export interface RequestContext {
|
|
|
174
186
|
* - [status, body, headers]: custom status, body, and headers
|
|
175
187
|
*/
|
|
176
188
|
export type ResponseResult =
|
|
177
|
-
|
|
|
178
|
-
| [number,
|
|
179
|
-
| [number,
|
|
189
|
+
| ResponseBody
|
|
190
|
+
| [number, ResponseBody]
|
|
191
|
+
| [number, ResponseBody, Record<string, string>];
|
|
180
192
|
|
|
181
193
|
/**
|
|
182
194
|
* Response object returned by handle method
|
|
183
195
|
*/
|
|
184
196
|
export interface Response {
|
|
185
197
|
status: number;
|
|
186
|
-
body:
|
|
198
|
+
body: unknown;
|
|
187
199
|
headers: Record<string, string>;
|
|
188
200
|
}
|
|
189
201
|
|
|
@@ -192,7 +204,7 @@ export interface Response {
|
|
|
192
204
|
*/
|
|
193
205
|
export interface RequestOptions {
|
|
194
206
|
headers?: Record<string, string>;
|
|
195
|
-
body?:
|
|
207
|
+
body?: unknown;
|
|
196
208
|
query?: Record<string, string>;
|
|
197
209
|
}
|
|
198
210
|
|