@schmock/core 2.0.3 → 2.1.1
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/index.d.ts +1 -0
- package/dist/interceptor.d.ts.map +1 -1
- package/dist/interceptor.js +55 -8
- package/dist/schmock.d.ts +692 -0
- package/package.json +2 -2
- package/src/interceptor.ts +57 -9
- package/src/steps/interceptor.steps.ts +54 -0
package/dist/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"interceptor.d.ts","sourceRoot":"","sources":["../src/interceptor.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"interceptor.d.ts","sourceRoot":"","sources":["../src/interceptor.ts"],"names":[],"mappings":"AA2JA;;GAEG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,CACN,MAAM,EAAE,OAAO,CAAC,UAAU,EAC1B,IAAI,EAAE,MAAM,EACZ,cAAc,CAAC,EAAE,OAAO,CAAC,cAAc,KACpC,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,EAC9B,OAAO,GAAE,OAAO,CAAC,gBAAqB,GACrC,OAAO,CAAC,eAAe,CA0JzB"}
|
package/dist/interceptor.js
CHANGED
|
@@ -19,6 +19,43 @@ function extractPathname(url) {
|
|
|
19
19
|
}
|
|
20
20
|
return urlWithoutQuery;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Extract origin (scheme + host + port) from a URL string, or null for
|
|
24
|
+
* relative URLs that don't carry one.
|
|
25
|
+
*/
|
|
26
|
+
function extractOrigin(url) {
|
|
27
|
+
if (!url.includes("://"))
|
|
28
|
+
return null;
|
|
29
|
+
try {
|
|
30
|
+
return new URL(url).origin;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Parse the user-supplied baseUrl option into its origin and path parts.
|
|
38
|
+
* - "/api" → { origin: null, path: "/api" }
|
|
39
|
+
* - "https://x.com/api/v1" → { origin: "https://x.com", path: "/api/v1" }
|
|
40
|
+
* - "https://x.com" → { origin: "https://x.com", path: "" }
|
|
41
|
+
*
|
|
42
|
+
* Trailing slash is stripped from the path so the segment-boundary check
|
|
43
|
+
* works the same way for "/api" and "/api/".
|
|
44
|
+
*/
|
|
45
|
+
function parseBaseUrl(baseUrl) {
|
|
46
|
+
if (baseUrl.includes("://")) {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = new URL(baseUrl);
|
|
49
|
+
const rawPath = parsed.pathname;
|
|
50
|
+
const path = rawPath === "/" ? "" : rawPath.replace(/\/$/, "");
|
|
51
|
+
return { origin: parsed.origin, path };
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Fall through to path-only handling
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { origin: null, path: baseUrl.replace(/\/$/, "") };
|
|
58
|
+
}
|
|
22
59
|
/**
|
|
23
60
|
* Extract query parameters from a URL string.
|
|
24
61
|
*/
|
|
@@ -114,15 +151,25 @@ export function createFetchInterceptor(handle, options = {}) {
|
|
|
114
151
|
? input.href
|
|
115
152
|
: input;
|
|
116
153
|
const path = extractPathname(urlString);
|
|
117
|
-
// BaseUrl filter — non-matching requests go straight to real fetch
|
|
118
|
-
//
|
|
154
|
+
// BaseUrl filter — non-matching requests go straight to real fetch.
|
|
155
|
+
// Two modes:
|
|
156
|
+
// - origin form ("https://api.example.com/v1"): require matching
|
|
157
|
+
// origin AND matching path prefix.
|
|
158
|
+
// - path form ("/api"): match pathname prefix only.
|
|
159
|
+
// Both enforce a segment boundary so "/api" doesn't match "/apiv2".
|
|
119
160
|
if (baseUrl) {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
161
|
+
const { origin: baseOrigin, path: basePath } = parseBaseUrl(baseUrl);
|
|
162
|
+
if (baseOrigin) {
|
|
163
|
+
const reqOrigin = extractOrigin(urlString);
|
|
164
|
+
if (reqOrigin !== baseOrigin) {
|
|
165
|
+
return originalFetch(input, init);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (basePath) {
|
|
169
|
+
const isMatch = path === basePath || path.startsWith(`${basePath}/`);
|
|
170
|
+
if (!isMatch) {
|
|
171
|
+
return originalFetch(input, init);
|
|
172
|
+
}
|
|
126
173
|
}
|
|
127
174
|
}
|
|
128
175
|
// Build adapter request
|
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schmock - Schema-driven mock API generator with callable API
|
|
3
|
+
* @packageDocumentation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
declare namespace Schmock {
|
|
7
|
+
type JSONSchema7 = import("json-schema").JSONSchema7;
|
|
8
|
+
/**
|
|
9
|
+
* HTTP methods supported by Schmock
|
|
10
|
+
*/
|
|
11
|
+
type HttpMethod =
|
|
12
|
+
| "GET"
|
|
13
|
+
| "POST"
|
|
14
|
+
| "PUT"
|
|
15
|
+
| "DELETE"
|
|
16
|
+
| "PATCH"
|
|
17
|
+
| "HEAD"
|
|
18
|
+
| "OPTIONS";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Route key format: 'METHOD /path'
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* 'GET /users'
|
|
25
|
+
* 'POST /users/:id'
|
|
26
|
+
* 'DELETE /api/posts/:postId/comments/:commentId'
|
|
27
|
+
*/
|
|
28
|
+
type RouteKey = `${HttpMethod} ${string}`;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Plugin interface for extending Schmock functionality
|
|
32
|
+
*/
|
|
33
|
+
interface Plugin {
|
|
34
|
+
/** Unique plugin identifier */
|
|
35
|
+
name: string;
|
|
36
|
+
/** Plugin version (semver) */
|
|
37
|
+
version?: string;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Called once when the plugin is added via .pipe()
|
|
41
|
+
* Use this to register routes or configure the instance at setup time
|
|
42
|
+
* @param instance - The callable mock instance
|
|
43
|
+
*/
|
|
44
|
+
install?(instance: CallableMockInstance): void;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Process the request through this plugin
|
|
48
|
+
* First plugin to set response becomes the generator, others transform
|
|
49
|
+
* @param context - Plugin context with request details
|
|
50
|
+
* @param response - Response from previous plugin (if any)
|
|
51
|
+
* @returns Updated context and response
|
|
52
|
+
*/
|
|
53
|
+
process(context: PluginContext, response?: unknown): PluginResult | Promise<PluginResult>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Called when an error occurs
|
|
57
|
+
* Can handle, transform, or suppress errors
|
|
58
|
+
* @param error - The error that occurred
|
|
59
|
+
* @param context - Plugin context
|
|
60
|
+
* @returns Modified error, response data, or void to continue error propagation
|
|
61
|
+
*/
|
|
62
|
+
onError?(error: Error, context: PluginContext): Error | ResponseResult | void | Promise<Error | ResponseResult | void>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Alias for response body type
|
|
67
|
+
*/
|
|
68
|
+
type ResponseBody = unknown;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Result returned by plugin process method
|
|
72
|
+
*/
|
|
73
|
+
interface PluginResult {
|
|
74
|
+
/** Updated context */
|
|
75
|
+
context: PluginContext;
|
|
76
|
+
/** Response data (if generated/modified) */
|
|
77
|
+
response?: unknown;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Context passed through plugin pipeline
|
|
82
|
+
*/
|
|
83
|
+
interface PluginContext {
|
|
84
|
+
/** Request path */
|
|
85
|
+
path: string;
|
|
86
|
+
/** Matched route configuration */
|
|
87
|
+
route: RouteConfig;
|
|
88
|
+
/** HTTP method */
|
|
89
|
+
method: HttpMethod;
|
|
90
|
+
/** Route parameters */
|
|
91
|
+
params: Record<string, string>;
|
|
92
|
+
/** Query parameters */
|
|
93
|
+
query: Record<string, string>;
|
|
94
|
+
/** Request headers */
|
|
95
|
+
headers: Record<string, string>;
|
|
96
|
+
/** Request body */
|
|
97
|
+
body?: unknown;
|
|
98
|
+
/** Shared state between plugins for this request */
|
|
99
|
+
state: Map<string, unknown>;
|
|
100
|
+
/** Route-specific state */
|
|
101
|
+
routeState?: Record<string, unknown>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ===== Callable API Types =====
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Global configuration options for the mock instance
|
|
108
|
+
*/
|
|
109
|
+
interface GlobalConfig {
|
|
110
|
+
/** Base path prefix for all routes */
|
|
111
|
+
namespace?: string;
|
|
112
|
+
/** Response delay in ms, or [min, max] for random delay */
|
|
113
|
+
delay?: number | [number, number];
|
|
114
|
+
/** Enable debug mode for detailed logging */
|
|
115
|
+
debug?: boolean;
|
|
116
|
+
/** Initial shared state object */
|
|
117
|
+
state?: Record<string, unknown>;
|
|
118
|
+
/**
|
|
119
|
+
* Maximum number of requests retained in history (FIFO eviction).
|
|
120
|
+
* Defaults to unbounded; set this to cap memory growth in long-running servers.
|
|
121
|
+
*/
|
|
122
|
+
maxHistorySize?: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Route-specific configuration options
|
|
127
|
+
*/
|
|
128
|
+
interface RouteConfig {
|
|
129
|
+
/** MIME type for content type validation (auto-detected if not provided) */
|
|
130
|
+
contentType?: string;
|
|
131
|
+
/** Per-route response delay in ms, or [min, max] for random delay (overrides global) */
|
|
132
|
+
delay?: number | [number, number];
|
|
133
|
+
/** Additional route-specific options */
|
|
134
|
+
[key: string]: unknown;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Generator types that can be passed to route definitions
|
|
139
|
+
*/
|
|
140
|
+
type Generator =
|
|
141
|
+
| GeneratorFunction
|
|
142
|
+
| StaticData
|
|
143
|
+
| JSONSchema7;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Function that generates responses
|
|
147
|
+
*/
|
|
148
|
+
type GeneratorFunction = (context: RequestContext) => ResponseResult | Promise<ResponseResult>;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Static data (non-function) that gets returned as-is
|
|
152
|
+
*/
|
|
153
|
+
type StaticData = string | number | boolean | null | undefined | Record<string, unknown> | unknown[];
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Context passed to generator functions
|
|
157
|
+
*/
|
|
158
|
+
interface RequestContext {
|
|
159
|
+
/** HTTP method */
|
|
160
|
+
method: HttpMethod;
|
|
161
|
+
/** Request path */
|
|
162
|
+
path: string;
|
|
163
|
+
/** Route parameters (e.g., :id) */
|
|
164
|
+
params: Record<string, string>;
|
|
165
|
+
/** Query string parameters */
|
|
166
|
+
query: Record<string, string>;
|
|
167
|
+
/** Request headers */
|
|
168
|
+
headers: Record<string, string>;
|
|
169
|
+
/** Request body (for POST, PUT, PATCH) */
|
|
170
|
+
body?: unknown;
|
|
171
|
+
/** Shared mutable state */
|
|
172
|
+
state: Record<string, unknown>;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Response result types:
|
|
177
|
+
* - Any value: returns as 200 OK
|
|
178
|
+
* - [status, body]: custom status with body
|
|
179
|
+
* - [status, body, headers]: custom status, body, and headers
|
|
180
|
+
*/
|
|
181
|
+
type ResponseResult =
|
|
182
|
+
| ResponseBody
|
|
183
|
+
| [number, unknown]
|
|
184
|
+
| [number, unknown, Record<string, string>];
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Response object returned by handle method
|
|
188
|
+
*/
|
|
189
|
+
interface Response {
|
|
190
|
+
status: number;
|
|
191
|
+
body: unknown;
|
|
192
|
+
headers: Record<string, string>;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Options for handle method
|
|
197
|
+
*/
|
|
198
|
+
interface RequestOptions {
|
|
199
|
+
headers?: Record<string, string>;
|
|
200
|
+
body?: unknown;
|
|
201
|
+
query?: Record<string, string>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Record of a single request handled by the mock
|
|
206
|
+
*/
|
|
207
|
+
interface RequestRecord {
|
|
208
|
+
/** HTTP method */
|
|
209
|
+
method: HttpMethod;
|
|
210
|
+
/** Request path (without namespace) */
|
|
211
|
+
path: string;
|
|
212
|
+
/** Extracted route parameters */
|
|
213
|
+
params: Record<string, string>;
|
|
214
|
+
/** Query parameters */
|
|
215
|
+
query: Record<string, string>;
|
|
216
|
+
/** Request headers */
|
|
217
|
+
headers: Record<string, string>;
|
|
218
|
+
/** Request body */
|
|
219
|
+
body: unknown;
|
|
220
|
+
/** Unix timestamp (ms) when request was handled */
|
|
221
|
+
timestamp: number;
|
|
222
|
+
/** Response returned for this request */
|
|
223
|
+
response: { status: number; body: unknown };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Main callable mock instance interface
|
|
228
|
+
*/
|
|
229
|
+
interface CallableMockInstance {
|
|
230
|
+
/**
|
|
231
|
+
* Define a route by calling the instance directly
|
|
232
|
+
*
|
|
233
|
+
* @param route - Route pattern in format 'METHOD /path'
|
|
234
|
+
* @param generator - Response generator (function, static data, or schema)
|
|
235
|
+
* @param config - Route-specific configuration
|
|
236
|
+
* @returns The same instance for method chaining
|
|
237
|
+
*
|
|
238
|
+
* @example
|
|
239
|
+
* ```typescript
|
|
240
|
+
* const mock = schmock()
|
|
241
|
+
* mock('GET /users', () => [...users], { contentType: 'application/json' })
|
|
242
|
+
* mock('POST /users', userData, { contentType: 'application/json' })
|
|
243
|
+
* ```
|
|
244
|
+
*/
|
|
245
|
+
(route: RouteKey, generator: Generator, config?: RouteConfig): CallableMockInstance;
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Add a plugin to the pipeline
|
|
249
|
+
*
|
|
250
|
+
* @param plugin - Plugin to add to the pipeline
|
|
251
|
+
* @returns The same instance for method chaining
|
|
252
|
+
*
|
|
253
|
+
* @example
|
|
254
|
+
* ```typescript
|
|
255
|
+
* mock('GET /users', generator, config)
|
|
256
|
+
* .pipe(authPlugin())
|
|
257
|
+
* .pipe(corsPlugin())
|
|
258
|
+
* ```
|
|
259
|
+
*/
|
|
260
|
+
pipe(plugin: Plugin): CallableMockInstance;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Handle a request and return a response
|
|
264
|
+
*
|
|
265
|
+
* @param method - HTTP method
|
|
266
|
+
* @param path - Request path
|
|
267
|
+
* @param options - Request options (headers, body, query)
|
|
268
|
+
* @returns Promise resolving to response object
|
|
269
|
+
*
|
|
270
|
+
* @example
|
|
271
|
+
* ```typescript
|
|
272
|
+
* const response = await mock.handle('GET', '/users', {
|
|
273
|
+
* headers: { 'Authorization': 'Bearer token' }
|
|
274
|
+
* })
|
|
275
|
+
* ```
|
|
276
|
+
*/
|
|
277
|
+
handle(method: HttpMethod, path: string, options?: RequestOptions): Promise<Response>;
|
|
278
|
+
|
|
279
|
+
// ===== Request Spy / History API =====
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Get all recorded requests, optionally filtered by method and path
|
|
283
|
+
*
|
|
284
|
+
* @param method - Filter by HTTP method
|
|
285
|
+
* @param path - Filter by request path
|
|
286
|
+
* @returns Array of request records
|
|
287
|
+
*/
|
|
288
|
+
history(): RequestRecord[];
|
|
289
|
+
history(method: HttpMethod, path: string): RequestRecord[];
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Check if any request was made, optionally for a specific route
|
|
293
|
+
*
|
|
294
|
+
* @param method - Filter by HTTP method
|
|
295
|
+
* @param path - Filter by request path
|
|
296
|
+
* @returns true if at least one matching request was recorded
|
|
297
|
+
*/
|
|
298
|
+
called(): boolean;
|
|
299
|
+
called(method: HttpMethod, path: string): boolean;
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Get the number of recorded requests, optionally for a specific route
|
|
303
|
+
*
|
|
304
|
+
* @param method - Filter by HTTP method
|
|
305
|
+
* @param path - Filter by request path
|
|
306
|
+
* @returns Number of matching requests
|
|
307
|
+
*/
|
|
308
|
+
callCount(): number;
|
|
309
|
+
callCount(method: HttpMethod, path: string): number;
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Get the most recent request, optionally for a specific route
|
|
313
|
+
*
|
|
314
|
+
* @param method - Filter by HTTP method
|
|
315
|
+
* @param path - Filter by request path
|
|
316
|
+
* @returns Most recent matching request record, or undefined
|
|
317
|
+
*/
|
|
318
|
+
lastRequest(): RequestRecord | undefined;
|
|
319
|
+
lastRequest(method: HttpMethod, path: string): RequestRecord | undefined;
|
|
320
|
+
|
|
321
|
+
// ===== Reset / Lifecycle =====
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Clear all routes, state, plugins, and history
|
|
325
|
+
*/
|
|
326
|
+
reset(): void;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Clear only request history, keep routes and state
|
|
330
|
+
*/
|
|
331
|
+
resetHistory(): void;
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Clear only state, keep routes and history
|
|
335
|
+
*/
|
|
336
|
+
resetState(): void;
|
|
337
|
+
|
|
338
|
+
// ===== Lifecycle Events =====
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Register an event listener
|
|
342
|
+
*/
|
|
343
|
+
on<E extends SchmockEvent>(event: E, listener: (data: SchmockEventMap[E]) => void): CallableMockInstance;
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Remove an event listener
|
|
347
|
+
*/
|
|
348
|
+
off<E extends SchmockEvent>(event: E, listener: (data: SchmockEventMap[E]) => void): CallableMockInstance;
|
|
349
|
+
|
|
350
|
+
// ===== Introspection =====
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Get all registered routes as an array of route info objects
|
|
354
|
+
*/
|
|
355
|
+
getRoutes(): RouteInfo[];
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Get the current shared state object
|
|
359
|
+
*/
|
|
360
|
+
getState(): Record<string, unknown>;
|
|
361
|
+
|
|
362
|
+
// ===== Standalone Server =====
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Start a standalone HTTP server
|
|
366
|
+
*
|
|
367
|
+
* @param port - Port to listen on (0 for random)
|
|
368
|
+
* @param hostname - Hostname to bind to (default: "127.0.0.1")
|
|
369
|
+
* @returns Promise resolving to server info with actual port and hostname
|
|
370
|
+
* @throws If the server is already running
|
|
371
|
+
*/
|
|
372
|
+
listen(port?: number, hostname?: string): Promise<ServerInfo>;
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Stop the standalone server (idempotent, no-op if not running)
|
|
376
|
+
*/
|
|
377
|
+
close(): void;
|
|
378
|
+
|
|
379
|
+
// ===== Fetch Interceptor =====
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Intercept globalThis.fetch and route requests through this mock.
|
|
383
|
+
* Client-side equivalent of listen().
|
|
384
|
+
*
|
|
385
|
+
* @param options - Intercept configuration
|
|
386
|
+
* @returns Handle with restore() to stop intercepting
|
|
387
|
+
* @throws If already intercepting (call restore() first)
|
|
388
|
+
*/
|
|
389
|
+
intercept(options?: InterceptOptions): InterceptHandle;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Information about a running standalone server
|
|
394
|
+
*/
|
|
395
|
+
interface ServerInfo {
|
|
396
|
+
/** Port the server is listening on */
|
|
397
|
+
port: number;
|
|
398
|
+
/** Hostname the server is bound to */
|
|
399
|
+
hostname: string;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ===== Response Helpers =====
|
|
403
|
+
|
|
404
|
+
interface PaginateOptions {
|
|
405
|
+
page?: number;
|
|
406
|
+
pageSize?: number;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
interface PaginatedResponse<T> {
|
|
410
|
+
data: T[];
|
|
411
|
+
page: number;
|
|
412
|
+
pageSize: number;
|
|
413
|
+
total: number;
|
|
414
|
+
totalPages: number;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ===== Adapter Types =====
|
|
418
|
+
|
|
419
|
+
interface AdapterRequest {
|
|
420
|
+
method: string;
|
|
421
|
+
path: string;
|
|
422
|
+
headers: Record<string, string>;
|
|
423
|
+
body?: unknown;
|
|
424
|
+
query: Record<string, string>;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
interface AdapterResponse {
|
|
428
|
+
status: number;
|
|
429
|
+
body: unknown;
|
|
430
|
+
headers: Record<string, string>;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
interface InterceptOptions {
|
|
434
|
+
/**
|
|
435
|
+
* Only intercept URLs matching this base.
|
|
436
|
+
*
|
|
437
|
+
* Two modes:
|
|
438
|
+
* - Path form ("/api"): match request pathnames whose prefix is the
|
|
439
|
+
* base path (with a segment-boundary check, so "/api" never
|
|
440
|
+
* matches "/apiv2").
|
|
441
|
+
* - Origin form ("https://api.example.com" or
|
|
442
|
+
* "https://api.example.com/v1"): require the request origin to
|
|
443
|
+
* match the base origin AND, if a base path is present, the
|
|
444
|
+
* request pathname to start with it. Relative-URL fetches
|
|
445
|
+
* (no origin) won't match an origin-form base.
|
|
446
|
+
*/
|
|
447
|
+
baseUrl?: string;
|
|
448
|
+
/** Pass unmatched routes to real fetch (default: true) */
|
|
449
|
+
passthrough?: boolean;
|
|
450
|
+
/** Modify request before Schmock handles it */
|
|
451
|
+
beforeRequest?: (
|
|
452
|
+
request: AdapterRequest,
|
|
453
|
+
) => AdapterRequest | void | Promise<AdapterRequest | void>;
|
|
454
|
+
/** Modify response before returning to caller */
|
|
455
|
+
beforeResponse?: (
|
|
456
|
+
response: AdapterResponse,
|
|
457
|
+
request: AdapterRequest,
|
|
458
|
+
) => AdapterResponse | void | Promise<AdapterResponse | void>;
|
|
459
|
+
/** Format errors into custom response bodies */
|
|
460
|
+
errorFormatter?: (error: Error) => unknown;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
interface InterceptHandle {
|
|
464
|
+
/** Stop intercepting and restore original fetch */
|
|
465
|
+
restore(): void;
|
|
466
|
+
/** Whether this interceptor is currently active */
|
|
467
|
+
readonly active: boolean;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ===== Lifecycle Events =====
|
|
471
|
+
|
|
472
|
+
interface RequestStartEvent {
|
|
473
|
+
method: HttpMethod;
|
|
474
|
+
path: string;
|
|
475
|
+
headers: Record<string, string>;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
interface RequestMatchEvent {
|
|
479
|
+
method: HttpMethod;
|
|
480
|
+
path: string;
|
|
481
|
+
routePath: string;
|
|
482
|
+
params: Record<string, string>;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
interface RequestNotFoundEvent {
|
|
486
|
+
method: HttpMethod;
|
|
487
|
+
path: string;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
interface RequestEndEvent {
|
|
491
|
+
method: HttpMethod;
|
|
492
|
+
path: string;
|
|
493
|
+
status: number;
|
|
494
|
+
duration: number;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
type SchmockEventMap = {
|
|
498
|
+
"request:start": RequestStartEvent;
|
|
499
|
+
"request:match": RequestMatchEvent;
|
|
500
|
+
"request:notfound": RequestNotFoundEvent;
|
|
501
|
+
"request:end": RequestEndEvent;
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
type SchmockEvent = keyof SchmockEventMap;
|
|
505
|
+
|
|
506
|
+
// ===== Introspection Types =====
|
|
507
|
+
|
|
508
|
+
interface RouteInfo {
|
|
509
|
+
method: HttpMethod;
|
|
510
|
+
path: string;
|
|
511
|
+
hasParams: boolean;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ===== OpenAPI Plugin Types =====
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Per-resource configuration override for OpenAPI plugin
|
|
518
|
+
*/
|
|
519
|
+
interface ResourceOverride {
|
|
520
|
+
/** Override: which property in list response holds the items array (e.g. "data") */
|
|
521
|
+
listWrapProperty?: string;
|
|
522
|
+
/** Override: force flat array for list (ignores any wrapper in the spec) */
|
|
523
|
+
listFlat?: boolean;
|
|
524
|
+
/** Override: JSON Schema for error responses (404, etc.) */
|
|
525
|
+
errorSchema?: JSONSchema7;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Response header definition from an OpenAPI spec
|
|
530
|
+
*/
|
|
531
|
+
interface ResponseHeaderDef {
|
|
532
|
+
schema?: JSONSchema7;
|
|
533
|
+
description: string;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Per-operation metadata auto-detected from spec or set via overrides
|
|
538
|
+
*/
|
|
539
|
+
interface CrudOperationMeta {
|
|
540
|
+
/** Full success response schema (wrapper + items) */
|
|
541
|
+
responseSchema?: JSONSchema7;
|
|
542
|
+
/** Response headers from spec */
|
|
543
|
+
responseHeaders?: Record<string, ResponseHeaderDef>;
|
|
544
|
+
/** Error response schemas keyed by status code */
|
|
545
|
+
errorSchemas?: Map<number, JSONSchema7>;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ===== Faker Plugin Types =====
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Context for schema-based data generation
|
|
552
|
+
*/
|
|
553
|
+
interface SchemaGenerationContext {
|
|
554
|
+
schema: JSONSchema7;
|
|
555
|
+
count?: number;
|
|
556
|
+
overrides?: Record<string, unknown>;
|
|
557
|
+
params?: Record<string, string>;
|
|
558
|
+
state?: Record<string, unknown>;
|
|
559
|
+
query?: Record<string, string>;
|
|
560
|
+
seed?: number;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Options for the faker plugin
|
|
565
|
+
*/
|
|
566
|
+
interface FakerPluginOptions {
|
|
567
|
+
schema: JSONSchema7;
|
|
568
|
+
count?: number;
|
|
569
|
+
overrides?: Record<string, unknown>;
|
|
570
|
+
seed?: number;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ===== Express Adapter Types =====
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Override parts of a request before Schmock handles it
|
|
577
|
+
*/
|
|
578
|
+
interface AdapterRequestOverride {
|
|
579
|
+
method?: string;
|
|
580
|
+
path?: string;
|
|
581
|
+
headers?: Record<string, string>;
|
|
582
|
+
body?: unknown;
|
|
583
|
+
query?: Record<string, string>;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Configuration options for Express adapter
|
|
588
|
+
*/
|
|
589
|
+
interface ExpressAdapterOptions {
|
|
590
|
+
errorFormatter?: (error: Error, req: unknown) => unknown;
|
|
591
|
+
passErrorsToNext?: boolean;
|
|
592
|
+
transformHeaders?: (
|
|
593
|
+
headers: Record<string, string | string[] | undefined>,
|
|
594
|
+
) => Record<string, string>;
|
|
595
|
+
transformQuery?: (query: Record<string, unknown>) => Record<string, string>;
|
|
596
|
+
beforeRequest?: (
|
|
597
|
+
req: unknown,
|
|
598
|
+
res: unknown,
|
|
599
|
+
) =>
|
|
600
|
+
| AdapterRequestOverride
|
|
601
|
+
| undefined
|
|
602
|
+
| Promise<AdapterRequestOverride | undefined>;
|
|
603
|
+
beforeResponse?: (
|
|
604
|
+
response: Response,
|
|
605
|
+
req: unknown,
|
|
606
|
+
res: unknown,
|
|
607
|
+
) => Response | undefined | Promise<Response | undefined>;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ===== Angular Adapter Types =====
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Configuration options for Angular adapter
|
|
614
|
+
*/
|
|
615
|
+
interface AngularAdapterOptions {
|
|
616
|
+
baseUrl?: string;
|
|
617
|
+
passthrough?: boolean;
|
|
618
|
+
errorFormatter?: (error: Error, request: unknown) => unknown;
|
|
619
|
+
transformRequest?: (request: unknown) => AdapterRequestOverride;
|
|
620
|
+
transformResponse?: (
|
|
621
|
+
response: Response,
|
|
622
|
+
request: unknown,
|
|
623
|
+
) => Response;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ===== OpenAPI Plugin Options =====
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Options for the OpenAPI plugin
|
|
630
|
+
*/
|
|
631
|
+
interface OpenApiOptions {
|
|
632
|
+
spec: string | object;
|
|
633
|
+
seed?: SeedConfig;
|
|
634
|
+
validateRequests?: boolean;
|
|
635
|
+
validateResponses?: boolean;
|
|
636
|
+
queryFeatures?: {
|
|
637
|
+
pagination?: boolean;
|
|
638
|
+
sorting?: boolean;
|
|
639
|
+
filtering?: boolean;
|
|
640
|
+
};
|
|
641
|
+
resources?: Record<string, ResourceOverride>;
|
|
642
|
+
debug?: boolean;
|
|
643
|
+
fakerSeed?: number;
|
|
644
|
+
security?: boolean;
|
|
645
|
+
/** Replace response schemas for specific routes. Key format: "METHOD /path" or "METHOD /path STATUS" */
|
|
646
|
+
schemas?: Record<string, import("json-schema").JSONSchema7>;
|
|
647
|
+
/** Called before generating a response body. Return a schema to replace the original, or void to keep it. */
|
|
648
|
+
onSchema?: (
|
|
649
|
+
schema: import("json-schema").JSONSchema7,
|
|
650
|
+
context: { method: string; path: string; params: Record<string, string>; query: Record<string, string>; headers: Record<string, string> },
|
|
651
|
+
) => import("json-schema").JSONSchema7 | undefined;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Seed data source: inline array, file path, or auto-generate count
|
|
656
|
+
*/
|
|
657
|
+
type SeedSource = unknown[] | string | { count: number };
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Seed configuration mapping resource names to seed sources
|
|
661
|
+
*/
|
|
662
|
+
type SeedConfig = Record<string, SeedSource>;
|
|
663
|
+
|
|
664
|
+
// ===== CLI Types =====
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Options for the CLI server
|
|
668
|
+
*/
|
|
669
|
+
interface CliOptions {
|
|
670
|
+
spec: string;
|
|
671
|
+
port?: number;
|
|
672
|
+
hostname?: string;
|
|
673
|
+
seed?: string;
|
|
674
|
+
cors?: boolean;
|
|
675
|
+
debug?: boolean;
|
|
676
|
+
fakerSeed?: number;
|
|
677
|
+
errors?: boolean;
|
|
678
|
+
watch?: boolean;
|
|
679
|
+
admin?: boolean;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Running CLI server instance
|
|
684
|
+
*/
|
|
685
|
+
interface CliServer {
|
|
686
|
+
server: import("node:http").Server;
|
|
687
|
+
port: number;
|
|
688
|
+
hostname: string;
|
|
689
|
+
close(): void;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@schmock/core",
|
|
3
3
|
"description": "Core functionality for Schmock",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.1.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"scripts": {
|
|
21
21
|
"build": "bun build:lib && bun build:types",
|
|
22
22
|
"build:lib": "bun build --minify --target node --outdir=dist src/index.ts",
|
|
23
|
-
"build:types": "rm -f tsconfig.tsbuildinfo && tsc --build",
|
|
23
|
+
"build:types": "rm -f tsconfig.tsbuildinfo && tsc --build && node scripts/embed-ambient-types.js",
|
|
24
24
|
"pretest": "rm -f src/*.js src/*.d.ts || true",
|
|
25
25
|
"test": "vitest",
|
|
26
26
|
"test:watch": "vitest --watch",
|
package/src/interceptor.ts
CHANGED
|
@@ -24,6 +24,45 @@ function extractPathname(url: string): string {
|
|
|
24
24
|
return urlWithoutQuery;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Extract origin (scheme + host + port) from a URL string, or null for
|
|
29
|
+
* relative URLs that don't carry one.
|
|
30
|
+
*/
|
|
31
|
+
function extractOrigin(url: string): string | null {
|
|
32
|
+
if (!url.includes("://")) return null;
|
|
33
|
+
try {
|
|
34
|
+
return new URL(url).origin;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse the user-supplied baseUrl option into its origin and path parts.
|
|
42
|
+
* - "/api" → { origin: null, path: "/api" }
|
|
43
|
+
* - "https://x.com/api/v1" → { origin: "https://x.com", path: "/api/v1" }
|
|
44
|
+
* - "https://x.com" → { origin: "https://x.com", path: "" }
|
|
45
|
+
*
|
|
46
|
+
* Trailing slash is stripped from the path so the segment-boundary check
|
|
47
|
+
* works the same way for "/api" and "/api/".
|
|
48
|
+
*/
|
|
49
|
+
function parseBaseUrl(baseUrl: string): {
|
|
50
|
+
origin: string | null;
|
|
51
|
+
path: string;
|
|
52
|
+
} {
|
|
53
|
+
if (baseUrl.includes("://")) {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = new URL(baseUrl);
|
|
56
|
+
const rawPath = parsed.pathname;
|
|
57
|
+
const path = rawPath === "/" ? "" : rawPath.replace(/\/$/, "");
|
|
58
|
+
return { origin: parsed.origin, path };
|
|
59
|
+
} catch {
|
|
60
|
+
// Fall through to path-only handling
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { origin: null, path: baseUrl.replace(/\/$/, "") };
|
|
64
|
+
}
|
|
65
|
+
|
|
27
66
|
/**
|
|
28
67
|
* Extract query parameters from a URL string.
|
|
29
68
|
*/
|
|
@@ -150,16 +189,25 @@ export function createFetchInterceptor(
|
|
|
150
189
|
|
|
151
190
|
const path = extractPathname(urlString);
|
|
152
191
|
|
|
153
|
-
// BaseUrl filter — non-matching requests go straight to real fetch
|
|
154
|
-
//
|
|
192
|
+
// BaseUrl filter — non-matching requests go straight to real fetch.
|
|
193
|
+
// Two modes:
|
|
194
|
+
// - origin form ("https://api.example.com/v1"): require matching
|
|
195
|
+
// origin AND matching path prefix.
|
|
196
|
+
// - path form ("/api"): match pathname prefix only.
|
|
197
|
+
// Both enforce a segment boundary so "/api" doesn't match "/apiv2".
|
|
155
198
|
if (baseUrl) {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
199
|
+
const { origin: baseOrigin, path: basePath } = parseBaseUrl(baseUrl);
|
|
200
|
+
if (baseOrigin) {
|
|
201
|
+
const reqOrigin = extractOrigin(urlString);
|
|
202
|
+
if (reqOrigin !== baseOrigin) {
|
|
203
|
+
return originalFetch(input, init);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (basePath) {
|
|
207
|
+
const isMatch = path === basePath || path.startsWith(`${basePath}/`);
|
|
208
|
+
if (!isMatch) {
|
|
209
|
+
return originalFetch(input, init);
|
|
210
|
+
}
|
|
163
211
|
}
|
|
164
212
|
}
|
|
165
213
|
|
|
@@ -130,6 +130,60 @@ describeFeature(feature, ({ Scenario, AfterEachScenario }) => {
|
|
|
130
130
|
});
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
+
Scenario("Origin-form baseUrl intercepts matching origin only", ({ Given, When, Then, And }) => {
|
|
134
|
+
Given("a Schmock instance with route \"GET /api/users\" returning users", () => {
|
|
135
|
+
setup();
|
|
136
|
+
mock("GET /api/users", [{ id: 1 }]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
And("fetch is intercepted with baseUrl \"https://api.example.com\"", () => {
|
|
140
|
+
handle = mock.intercept({ baseUrl: "https://api.example.com" });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
When("I fetch \"https://api.example.com/api/users\"", async () => {
|
|
144
|
+
fetchResponse = await fetch("https://api.example.com/api/users");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
Then("the response status should be {int}", (_, status: number) => {
|
|
148
|
+
expect(fetchResponse?.status).toBe(status);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
When("I fetch \"https://other.example.com/api/users\"", async () => {
|
|
152
|
+
await fetch("https://other.example.com/api/users");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
Then("the original fetch should have been called", () => {
|
|
156
|
+
expect(savedFetch).toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
Scenario("Origin-form baseUrl with a path prefix requires both to match", ({ Given, When, Then, And }) => {
|
|
161
|
+
Given("a Schmock instance with route \"GET /v1/users\" returning users", () => {
|
|
162
|
+
setup();
|
|
163
|
+
mock("GET /v1/users", [{ id: 1 }]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
And("fetch is intercepted with baseUrl \"https://api.example.com/v1\"", () => {
|
|
167
|
+
handle = mock.intercept({ baseUrl: "https://api.example.com/v1" });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
When("I fetch \"https://api.example.com/v1/users\"", async () => {
|
|
171
|
+
fetchResponse = await fetch("https://api.example.com/v1/users");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
Then("the response status should be {int}", (_, status: number) => {
|
|
175
|
+
expect(fetchResponse?.status).toBe(status);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
When("I fetch \"https://api.example.com/users\"", async () => {
|
|
179
|
+
await fetch("https://api.example.com/users");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
Then("the original fetch should have been called", () => {
|
|
183
|
+
expect(savedFetch).toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
133
187
|
Scenario("beforeRequest hook modifies the request", ({ Given, When, Then, And }) => {
|
|
134
188
|
Given("a Schmock instance with route \"GET /api/users\" that reads headers", () => {
|
|
135
189
|
setup();
|