@schmock/core 2.4.0 → 2.5.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/README.md +129 -0
- package/dist/abort.d.ts +11 -1
- package/dist/abort.js +13 -2
- package/dist/adapter.d.ts +19 -0
- package/dist/adapter.js +17 -0
- package/dist/admission.d.ts +21 -0
- package/dist/admission.js +39 -0
- package/dist/binary.d.ts +0 -1
- package/dist/builder.d.ts +16 -32
- package/dist/builder.js +397 -903
- package/dist/constants.d.ts +33 -2
- package/dist/constants.js +73 -1
- package/dist/debug-logger.d.ts +10 -0
- package/dist/debug-logger.js +31 -0
- package/dist/delay.d.ts +12 -0
- package/dist/delay.js +37 -0
- package/dist/errors.d.ts +13 -2
- package/dist/errors.js +21 -2
- package/dist/events.d.ts +17 -0
- package/dist/events.js +58 -0
- package/dist/generations.d.ts +43 -0
- package/dist/generations.js +75 -0
- package/dist/headers.d.ts +27 -0
- package/dist/headers.js +57 -0
- package/dist/helpers.d.ts +9 -10
- package/dist/helpers.js +4 -1
- package/dist/history.d.ts +56 -0
- package/dist/history.js +230 -0
- package/dist/http-helpers.d.ts +110 -5
- package/dist/http-helpers.js +328 -46
- package/dist/index.d.ts +213 -31
- package/dist/index.js +17 -9
- package/dist/interceptor.d.ts +15 -11
- package/dist/interceptor.js +241 -164
- package/dist/node-server.d.ts +27 -0
- package/dist/node-server.js +166 -0
- package/dist/parser.d.ts +0 -1
- package/dist/parser.js +145 -22
- package/dist/plugin-hooks.d.ts +40 -0
- package/dist/plugin-hooks.js +192 -0
- package/dist/plugin-pipeline.d.ts +0 -1
- package/dist/plugin-pipeline.js +25 -4
- package/dist/response-normalizer.d.ts +36 -1
- package/dist/response-normalizer.js +102 -0
- package/dist/response-parser.d.ts +19 -1
- package/dist/response-parser.js +77 -19
- package/dist/route-matcher.d.ts +0 -1
- package/dist/route-table.d.ts +64 -0
- package/dist/route-table.js +220 -0
- package/dist/types.d.ts +27 -1
- package/package.json +8 -3
- package/dist/abort.d.ts.map +0 -1
- package/dist/binary.d.ts.map +0 -1
- package/dist/builder.d.ts.map +0 -1
- package/dist/constants.d.ts.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/helpers.d.ts.map +0 -1
- package/dist/http-helpers.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/interceptor.d.ts.map +0 -1
- package/dist/parser.d.ts.map +0 -1
- package/dist/plugin-pipeline.d.ts.map +0 -1
- package/dist/response-normalizer.d.ts.map +0 -1
- package/dist/response-parser.d.ts.map +0 -1
- package/dist/route-matcher.d.ts.map +0 -1
- package/dist/types.d.ts.map +0 -1
package/dist/constants.d.ts
CHANGED
|
@@ -9,6 +9,10 @@ export declare function markResponseException<T extends ResponseLike>(response:
|
|
|
9
9
|
export declare function getResponseException(response: ResponseLike): Error | undefined;
|
|
10
10
|
export declare const HTTP_METHODS: readonly HttpMethod[];
|
|
11
11
|
export declare function isHttpMethod(method: string): method is HttpMethod;
|
|
12
|
+
/**
|
|
13
|
+
* Uppercase `method` and narrow it to an {@link HttpMethod}.
|
|
14
|
+
* @throws InvalidHttpMethodError (code `INVALID_HTTP_METHOD`) for any other verb
|
|
15
|
+
*/
|
|
12
16
|
export declare function toHttpMethod(method: string): HttpMethod;
|
|
13
17
|
export declare function normalizePath(path: string): string;
|
|
14
18
|
/**
|
|
@@ -25,6 +29,28 @@ export declare function normalizePath(path: string): string;
|
|
|
25
29
|
* spelling is preferable to silently rewriting the caller's path.
|
|
26
30
|
*/
|
|
27
31
|
export declare function canonicalizePath(path: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Parse a `baseUrl` or namespace into its origin and canonical path prefix.
|
|
34
|
+
*
|
|
35
|
+
* - "/api/" → { origin: null, path: "/api" }
|
|
36
|
+
* - "api" → { origin: null, path: "/api" }
|
|
37
|
+
* - "https://x.com/api/v1" → { origin: "https://x.com", path: "/api/v1" }
|
|
38
|
+
* - "https://x.com" → { origin: "https://x.com", path: "" }
|
|
39
|
+
*
|
|
40
|
+
* The path is canonicalized like request paths are (so "/café" and
|
|
41
|
+
* "/caf%C3%A9" are one prefix), gains a leading slash when it has none, and
|
|
42
|
+
* loses one trailing slash. A value containing "://" that is not a valid URL
|
|
43
|
+
* is read as a path.
|
|
44
|
+
*/
|
|
45
|
+
export declare function parsePathPrefix(prefix: string): Schmock.PathPrefix;
|
|
46
|
+
/**
|
|
47
|
+
* Whether `path` lies under the prefix, on a segment boundary: "/api" matches
|
|
48
|
+
* "/api" and "/api/users" but never "/apiv2". `path` is canonicalized first,
|
|
49
|
+
* so a raw or an encoded spelling of the same path match alike. Only the path
|
|
50
|
+
* is compared; checking `prefix.origin` against the request's origin is the
|
|
51
|
+
* caller's job.
|
|
52
|
+
*/
|
|
53
|
+
export declare function matchPathPrefix(prefix: Schmock.PathPrefix, path: string): boolean;
|
|
28
54
|
/**
|
|
29
55
|
* Decode a single captured path parameter.
|
|
30
56
|
*
|
|
@@ -54,11 +80,16 @@ export declare function isRouteNotFound(response: {
|
|
|
54
80
|
* Check if a value is a status tuple: [status, body] or [status, body, headers]
|
|
55
81
|
* Guards against misinterpreting numeric arrays like [1, 2, 3] as tuples.
|
|
56
82
|
*
|
|
83
|
+
* Only the length and the status are checked, so the third element is typed
|
|
84
|
+
* `unknown`: a caller that reads it as headers must check it is a string
|
|
85
|
+
* record first (core's response parser rejects anything else with
|
|
86
|
+
* INVALID_RESPONSE). Rejecting such a tuple here instead would make the parser
|
|
87
|
+
* treat `[200, "x", null]` as plain array data and answer 200.
|
|
88
|
+
*
|
|
57
89
|
* Known ambiguity: a length-2 numeric array whose first element happens to
|
|
58
90
|
* be in the HTTP-status range (e.g. [200, 300] as legitimate data) is
|
|
59
91
|
* indistinguishable from a status tuple by shape alone. Prefer the explicit
|
|
60
92
|
* status() helper or return an object response when the data could collide.
|
|
61
93
|
*/
|
|
62
|
-
export declare function isStatusTuple(value: unknown): value is [number, unknown] | [number, unknown,
|
|
94
|
+
export declare function isStatusTuple(value: unknown): value is [number, unknown] | [number, unknown, unknown];
|
|
63
95
|
export {};
|
|
64
|
-
//# sourceMappingURL=constants.d.ts.map
|
package/dist/constants.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { InvalidHttpMethodError } from "./errors.js";
|
|
1
2
|
export const ROUTE_NOT_FOUND_CODE = "ROUTE_NOT_FOUND";
|
|
2
3
|
const RESPONSE_ORIGIN = Symbol.for("@schmock/core.response-origin");
|
|
3
4
|
function setResponseOrigin(response, origin) {
|
|
@@ -44,10 +45,14 @@ export const HTTP_METHODS = [
|
|
|
44
45
|
export function isHttpMethod(method) {
|
|
45
46
|
return HTTP_METHODS.includes(method);
|
|
46
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Uppercase `method` and narrow it to an {@link HttpMethod}.
|
|
50
|
+
* @throws InvalidHttpMethodError (code `INVALID_HTTP_METHOD`) for any other verb
|
|
51
|
+
*/
|
|
47
52
|
export function toHttpMethod(method) {
|
|
48
53
|
const upper = method.toUpperCase();
|
|
49
54
|
if (!isHttpMethod(upper)) {
|
|
50
|
-
throw new
|
|
55
|
+
throw new InvalidHttpMethodError(method);
|
|
51
56
|
}
|
|
52
57
|
return upper;
|
|
53
58
|
}
|
|
@@ -72,6 +77,13 @@ const PATH_ENCODED_ASCII = new Set([
|
|
|
72
77
|
"}",
|
|
73
78
|
]);
|
|
74
79
|
const PERCENT_TRIPLET = /^%[0-9A-Fa-f]{2}$/;
|
|
80
|
+
/**
|
|
81
|
+
* Paths made only of characters the loop below copies unchanged: printable
|
|
82
|
+
* ASCII minus {@link PATH_ENCODED_ASCII} and minus `%`, which always takes the
|
|
83
|
+
* loop so a lowercase triplet is still uppercased. `&-;` starts after `%`
|
|
84
|
+
* (0x25) on purpose.
|
|
85
|
+
*/
|
|
86
|
+
const ALREADY_CANONICAL_PATH = /^[!$&-;=@-\]_a-z|~]*$/;
|
|
75
87
|
/** UTF-8 for U+FFFD, the URL parser's substitute for a lone surrogate. */
|
|
76
88
|
const ENCODED_REPLACEMENT_CHARACTER = "%EF%BF%BD";
|
|
77
89
|
/**
|
|
@@ -88,6 +100,10 @@ const ENCODED_REPLACEMENT_CHARACTER = "%EF%BF%BD";
|
|
|
88
100
|
* spelling is preferable to silently rewriting the caller's path.
|
|
89
101
|
*/
|
|
90
102
|
export function canonicalizePath(path) {
|
|
103
|
+
// Fast path for the common case, which runs on every request: nothing to
|
|
104
|
+
// encode and no percent triplet to uppercase.
|
|
105
|
+
if (ALREADY_CANONICAL_PATH.test(path))
|
|
106
|
+
return path;
|
|
91
107
|
let result = "";
|
|
92
108
|
for (let index = 0; index < path.length;) {
|
|
93
109
|
if (path[index] === "%" &&
|
|
@@ -115,6 +131,56 @@ export function canonicalizePath(path) {
|
|
|
115
131
|
}
|
|
116
132
|
return result;
|
|
117
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* The one trailing-slash rule for a path prefix: a single trailing slash is
|
|
136
|
+
* dropped, so "/api/" and "/api" are the same prefix, and the root "/" becomes
|
|
137
|
+
* "" (matches every path).
|
|
138
|
+
*/
|
|
139
|
+
function trimPrefixPath(canonicalPath) {
|
|
140
|
+
return canonicalPath === "/" ? "" : canonicalPath.replace(/\/$/, "");
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Parse a `baseUrl` or namespace into its origin and canonical path prefix.
|
|
144
|
+
*
|
|
145
|
+
* - "/api/" → { origin: null, path: "/api" }
|
|
146
|
+
* - "api" → { origin: null, path: "/api" }
|
|
147
|
+
* - "https://x.com/api/v1" → { origin: "https://x.com", path: "/api/v1" }
|
|
148
|
+
* - "https://x.com" → { origin: "https://x.com", path: "" }
|
|
149
|
+
*
|
|
150
|
+
* The path is canonicalized like request paths are (so "/café" and
|
|
151
|
+
* "/caf%C3%A9" are one prefix), gains a leading slash when it has none, and
|
|
152
|
+
* loses one trailing slash. A value containing "://" that is not a valid URL
|
|
153
|
+
* is read as a path.
|
|
154
|
+
*/
|
|
155
|
+
export function parsePathPrefix(prefix) {
|
|
156
|
+
if (prefix.includes("://")) {
|
|
157
|
+
try {
|
|
158
|
+
const url = new URL(prefix);
|
|
159
|
+
return {
|
|
160
|
+
origin: url.origin,
|
|
161
|
+
path: trimPrefixPath(canonicalizePath(url.pathname)),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// Not a URL after all: read it as a path below.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const rooted = prefix.startsWith("/") ? prefix : `/${prefix}`;
|
|
169
|
+
return { origin: null, path: trimPrefixPath(canonicalizePath(rooted)) };
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Whether `path` lies under the prefix, on a segment boundary: "/api" matches
|
|
173
|
+
* "/api" and "/api/users" but never "/apiv2". `path` is canonicalized first,
|
|
174
|
+
* so a raw or an encoded spelling of the same path match alike. Only the path
|
|
175
|
+
* is compared; checking `prefix.origin` against the request's origin is the
|
|
176
|
+
* caller's job.
|
|
177
|
+
*/
|
|
178
|
+
export function matchPathPrefix(prefix, path) {
|
|
179
|
+
if (prefix.path === "")
|
|
180
|
+
return true;
|
|
181
|
+
const canonicalPath = canonicalizePath(path);
|
|
182
|
+
return (canonicalPath === prefix.path || canonicalPath.startsWith(`${prefix.path}/`));
|
|
183
|
+
}
|
|
118
184
|
/**
|
|
119
185
|
* Decode a single captured path parameter.
|
|
120
186
|
*
|
|
@@ -162,6 +228,12 @@ export function isRouteNotFound(response) {
|
|
|
162
228
|
* Check if a value is a status tuple: [status, body] or [status, body, headers]
|
|
163
229
|
* Guards against misinterpreting numeric arrays like [1, 2, 3] as tuples.
|
|
164
230
|
*
|
|
231
|
+
* Only the length and the status are checked, so the third element is typed
|
|
232
|
+
* `unknown`: a caller that reads it as headers must check it is a string
|
|
233
|
+
* record first (core's response parser rejects anything else with
|
|
234
|
+
* INVALID_RESPONSE). Rejecting such a tuple here instead would make the parser
|
|
235
|
+
* treat `[200, "x", null]` as plain array data and answer 200.
|
|
236
|
+
*
|
|
165
237
|
* Known ambiguity: a length-2 numeric array whose first element happens to
|
|
166
238
|
* be in the HTTP-status range (e.g. [200, 300] as legitimate data) is
|
|
167
239
|
* indistinguishable from a status tuple by shape alone. Prefer the explicit
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug logger that respects debug mode configuration
|
|
3
|
+
*/
|
|
4
|
+
export declare class DebugLogger {
|
|
5
|
+
private enabled;
|
|
6
|
+
constructor(enabled?: boolean);
|
|
7
|
+
log(category: string, message: string, data?: unknown): void;
|
|
8
|
+
time(label: string): void;
|
|
9
|
+
timeEnd(label: string): void;
|
|
10
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug logger that respects debug mode configuration
|
|
3
|
+
*/
|
|
4
|
+
export class DebugLogger {
|
|
5
|
+
enabled;
|
|
6
|
+
constructor(enabled = false) {
|
|
7
|
+
this.enabled = enabled;
|
|
8
|
+
}
|
|
9
|
+
log(category, message, data) {
|
|
10
|
+
if (!this.enabled)
|
|
11
|
+
return;
|
|
12
|
+
const timestamp = new Date().toISOString();
|
|
13
|
+
const prefix = `[${timestamp}] [SCHMOCK:${category.toUpperCase()}]`;
|
|
14
|
+
if (data) {
|
|
15
|
+
console.log(`${prefix} ${message}`, data);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
console.log(`${prefix} ${message}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
time(label) {
|
|
22
|
+
if (!this.enabled)
|
|
23
|
+
return;
|
|
24
|
+
console.time(`[SCHMOCK] ${label}`);
|
|
25
|
+
}
|
|
26
|
+
timeEnd(label) {
|
|
27
|
+
if (!this.enabled)
|
|
28
|
+
return;
|
|
29
|
+
console.timeEnd(`[SCHMOCK] ${label}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
package/dist/delay.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type ResponseDelay = number | [number, number];
|
|
2
|
+
/**
|
|
3
|
+
* Apply configured response delay: the route's own when set, the mock's
|
|
4
|
+
* otherwise. Supports both fixed delays and random delays within a range. An
|
|
5
|
+
* abort ends the wait with the signal's reason.
|
|
6
|
+
*/
|
|
7
|
+
export declare function applyResponseDelay(input: {
|
|
8
|
+
routeDelay?: ResponseDelay;
|
|
9
|
+
globalDelay?: ResponseDelay;
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
}): Promise<void>;
|
|
12
|
+
export {};
|
package/dist/delay.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { throwIfAborted } from "./abort.js";
|
|
2
|
+
/**
|
|
3
|
+
* Apply configured response delay: the route's own when set, the mock's
|
|
4
|
+
* otherwise. Supports both fixed delays and random delays within a range. An
|
|
5
|
+
* abort ends the wait with the signal's reason.
|
|
6
|
+
*/
|
|
7
|
+
export async function applyResponseDelay(input) {
|
|
8
|
+
const { signal } = input;
|
|
9
|
+
const effectiveDelay = input.routeDelay ?? input.globalDelay;
|
|
10
|
+
if (!effectiveDelay) {
|
|
11
|
+
throwIfAborted(signal);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const configuredMs = Array.isArray(effectiveDelay)
|
|
15
|
+
? Math.random() * (effectiveDelay[1] - effectiveDelay[0]) +
|
|
16
|
+
effectiveDelay[0]
|
|
17
|
+
: effectiveDelay;
|
|
18
|
+
const ms = Math.max(0, configuredMs);
|
|
19
|
+
throwIfAborted(signal);
|
|
20
|
+
await new Promise((resolve, reject) => {
|
|
21
|
+
const finish = () => {
|
|
22
|
+
signal?.removeEventListener("abort", abort);
|
|
23
|
+
resolve();
|
|
24
|
+
};
|
|
25
|
+
const abort = () => {
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
try {
|
|
28
|
+
throwIfAborted(signal);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
reject(error);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const timer = setTimeout(finish, ms);
|
|
35
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
36
|
+
});
|
|
37
|
+
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -42,6 +42,14 @@ export declare class InvalidResponseError extends SchmockError {
|
|
|
42
42
|
export declare class PluginError extends SchmockError {
|
|
43
43
|
constructor(pluginName: string, error: Error);
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Error thrown by `toHttpMethod` for a string that is not one of the HTTP
|
|
47
|
+
* methods Schmock routes (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`,
|
|
48
|
+
* `OPTIONS`), compared case-insensitively.
|
|
49
|
+
*/
|
|
50
|
+
export declare class InvalidHttpMethodError extends SchmockError {
|
|
51
|
+
constructor(method: string);
|
|
52
|
+
}
|
|
45
53
|
/**
|
|
46
54
|
* Error thrown when route definition is invalid
|
|
47
55
|
*/
|
|
@@ -62,8 +70,11 @@ export declare class SchemaGenerationError extends SchmockError {
|
|
|
62
70
|
}
|
|
63
71
|
/**
|
|
64
72
|
* Error thrown when resource limits are exceeded
|
|
73
|
+
*
|
|
74
|
+
* @param path - Optional location of the breach (for example the schema path
|
|
75
|
+
* `$.properties.items`). When given it is appended to the message and added
|
|
76
|
+
* to the context; without it both keep their original shape.
|
|
65
77
|
*/
|
|
66
78
|
export declare class ResourceLimitError extends SchmockError {
|
|
67
|
-
constructor(resource: string, limit: number, actual?: number);
|
|
79
|
+
constructor(resource: string, limit: number, actual?: number, path?: string);
|
|
68
80
|
}
|
|
69
|
-
//# sourceMappingURL=errors.d.ts.map
|
package/dist/errors.js
CHANGED
|
@@ -94,6 +94,19 @@ export class PluginError extends SchmockError {
|
|
|
94
94
|
this.name = "PluginError";
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Error thrown by `toHttpMethod` for a string that is not one of the HTTP
|
|
99
|
+
* methods Schmock routes (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`,
|
|
100
|
+
* `OPTIONS`), compared case-insensitively.
|
|
101
|
+
*/
|
|
102
|
+
export class InvalidHttpMethodError extends SchmockError {
|
|
103
|
+
constructor(method) {
|
|
104
|
+
super(`Invalid HTTP method: "${method}"`, "INVALID_HTTP_METHOD", {
|
|
105
|
+
method,
|
|
106
|
+
});
|
|
107
|
+
this.name = "InvalidHttpMethodError";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
97
110
|
/**
|
|
98
111
|
* Error thrown when route definition is invalid
|
|
99
112
|
*/
|
|
@@ -123,10 +136,16 @@ export class SchemaGenerationError extends SchmockError {
|
|
|
123
136
|
}
|
|
124
137
|
/**
|
|
125
138
|
* Error thrown when resource limits are exceeded
|
|
139
|
+
*
|
|
140
|
+
* @param path - Optional location of the breach (for example the schema path
|
|
141
|
+
* `$.properties.items`). When given it is appended to the message and added
|
|
142
|
+
* to the context; without it both keep their original shape.
|
|
126
143
|
*/
|
|
127
144
|
export class ResourceLimitError extends SchmockError {
|
|
128
|
-
constructor(resource, limit, actual) {
|
|
129
|
-
super(`Resource limit exceeded for ${resource}: limit=${limit}${actual ? `, actual=${actual}` : ""}
|
|
145
|
+
constructor(resource, limit, actual, path) {
|
|
146
|
+
super(`Resource limit exceeded for ${resource}: limit=${limit}${actual ? `, actual=${actual}` : ""}${path === undefined ? "" : ` at ${path}`}`, "RESOURCE_LIMIT_ERROR", path === undefined
|
|
147
|
+
? { resource, limit, actual }
|
|
148
|
+
: { resource, limit, actual, path });
|
|
130
149
|
this.name = "ResourceLimitError";
|
|
131
150
|
}
|
|
132
151
|
}
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { DebugLogger } from "./debug-logger.js";
|
|
2
|
+
/**
|
|
3
|
+
* The mock's lifecycle event listeners.
|
|
4
|
+
*
|
|
5
|
+
* Every listener receives one frozen snapshot of the event, so a listener
|
|
6
|
+
* cannot edit what the next one (or the request) sees. A listener that throws
|
|
7
|
+
* or rejects is logged and never reaches the request.
|
|
8
|
+
*/
|
|
9
|
+
export declare class MockEvents {
|
|
10
|
+
#private;
|
|
11
|
+
constructor(logger: DebugLogger);
|
|
12
|
+
on<E extends Schmock.SchmockEvent>(event: E, listener: (data: Schmock.SchmockEventMap[E]) => void): void;
|
|
13
|
+
off<E extends Schmock.SchmockEvent>(event: E, listener: (data: Schmock.SchmockEventMap[E]) => void): void;
|
|
14
|
+
emit<E extends Schmock.SchmockEvent>(event: E, data: Schmock.SchmockEventMap[E]): void;
|
|
15
|
+
/** Remove every listener. */
|
|
16
|
+
clear(): void;
|
|
17
|
+
}
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { errorMessage } from "./errors.js";
|
|
2
|
+
import { isThenable } from "./plugin-hooks.js";
|
|
3
|
+
/**
|
|
4
|
+
* The mock's lifecycle event listeners.
|
|
5
|
+
*
|
|
6
|
+
* Every listener receives one frozen snapshot of the event, so a listener
|
|
7
|
+
* cannot edit what the next one (or the request) sees. A listener that throws
|
|
8
|
+
* or rejects is logged and never reaches the request.
|
|
9
|
+
*/
|
|
10
|
+
export class MockEvents {
|
|
11
|
+
// biome-ignore lint/complexity/noBannedTypes: internal storage for event listeners with varying signatures
|
|
12
|
+
#listeners = new Map();
|
|
13
|
+
#logger;
|
|
14
|
+
constructor(logger) {
|
|
15
|
+
this.#logger = logger;
|
|
16
|
+
}
|
|
17
|
+
on(event, listener) {
|
|
18
|
+
let set = this.#listeners.get(event);
|
|
19
|
+
if (!set) {
|
|
20
|
+
set = new Set();
|
|
21
|
+
this.#listeners.set(event, set);
|
|
22
|
+
}
|
|
23
|
+
set.add(listener);
|
|
24
|
+
}
|
|
25
|
+
off(event, listener) {
|
|
26
|
+
this.#listeners.get(event)?.delete(listener);
|
|
27
|
+
}
|
|
28
|
+
emit(event, data) {
|
|
29
|
+
const set = this.#listeners.get(event);
|
|
30
|
+
if (!set)
|
|
31
|
+
return;
|
|
32
|
+
const snapshot = { ...data };
|
|
33
|
+
if ("headers" in data) {
|
|
34
|
+
snapshot.headers = Object.freeze({ ...data.headers });
|
|
35
|
+
}
|
|
36
|
+
if ("params" in data) {
|
|
37
|
+
snapshot.params = Object.freeze({ ...data.params });
|
|
38
|
+
}
|
|
39
|
+
const eventData = Object.freeze(snapshot);
|
|
40
|
+
for (const listener of [...set]) {
|
|
41
|
+
try {
|
|
42
|
+
const listenerResult = listener(eventData);
|
|
43
|
+
if (isThenable(listenerResult)) {
|
|
44
|
+
void Promise.resolve(listenerResult).catch((error) => {
|
|
45
|
+
this.#logger.log("event", `${event} listener rejected: ${errorMessage(error)}`);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
this.#logger.log("event", `${event} listener failed: ${errorMessage(error)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Remove every listener. */
|
|
55
|
+
clear() {
|
|
56
|
+
this.#listeners.clear();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** One period of the mock between two resets. */
|
|
2
|
+
export interface RequestGeneration {
|
|
3
|
+
activeAdmissions: number;
|
|
4
|
+
/** Set once retired: the plugins it still owes an uninstall. */
|
|
5
|
+
retiredPlugins?: readonly Schmock.Plugin[];
|
|
6
|
+
}
|
|
7
|
+
type Uninstall = (plugins: readonly Schmock.Plugin[]) => void;
|
|
8
|
+
/**
|
|
9
|
+
* The mock's request generations. `reset()` retires the current one, but the
|
|
10
|
+
* plugins it had installed are uninstalled only once its last in-flight
|
|
11
|
+
* request settles, so no admitted request loses its plugins mid-flight.
|
|
12
|
+
*/
|
|
13
|
+
export declare class RequestGenerations {
|
|
14
|
+
#private;
|
|
15
|
+
constructor(uninstall: Uninstall);
|
|
16
|
+
/**
|
|
17
|
+
* Whether a request's generation is still the live one. Only its requests
|
|
18
|
+
* emit lifecycle events and record history.
|
|
19
|
+
*/
|
|
20
|
+
isCurrent(generation: RequestGeneration): boolean;
|
|
21
|
+
/** Count one more in-flight request in the current generation. */
|
|
22
|
+
admit(): RequestGeneration;
|
|
23
|
+
/**
|
|
24
|
+
* Count a request out. The last one out of a retired generation runs the
|
|
25
|
+
* uninstall that generation owes.
|
|
26
|
+
*/
|
|
27
|
+
release(generation: RequestGeneration): void;
|
|
28
|
+
/** Start a new generation; the previous one is returned for `retire()`. */
|
|
29
|
+
advance(): RequestGeneration;
|
|
30
|
+
/**
|
|
31
|
+
* Retire a generation that owes `plugins` an uninstall: at once when no
|
|
32
|
+
* request of it is in flight, otherwise when its last request settles.
|
|
33
|
+
*/
|
|
34
|
+
retire(generation: RequestGeneration, plugins: readonly Schmock.Plugin[]): void;
|
|
35
|
+
/**
|
|
36
|
+
* Run the uninstall a retired generation still owes this plugin object, now,
|
|
37
|
+
* before it is installed again. Left to the retired generation, it would run
|
|
38
|
+
* when that generation's last request settles — after the new install() —
|
|
39
|
+
* and tear down the live installation.
|
|
40
|
+
*/
|
|
41
|
+
uninstallBeforeReinstall(plugin: Schmock.Plugin): void;
|
|
42
|
+
}
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mock's request generations. `reset()` retires the current one, but the
|
|
3
|
+
* plugins it had installed are uninstalled only once its last in-flight
|
|
4
|
+
* request settles, so no admitted request loses its plugins mid-flight.
|
|
5
|
+
*/
|
|
6
|
+
export class RequestGenerations {
|
|
7
|
+
#current = { activeAdmissions: 0 };
|
|
8
|
+
/** Retired generations whose uninstall waits for in-flight requests. */
|
|
9
|
+
#retired = new Set();
|
|
10
|
+
#uninstall;
|
|
11
|
+
constructor(uninstall) {
|
|
12
|
+
this.#uninstall = uninstall;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Whether a request's generation is still the live one. Only its requests
|
|
16
|
+
* emit lifecycle events and record history.
|
|
17
|
+
*/
|
|
18
|
+
isCurrent(generation) {
|
|
19
|
+
return generation === this.#current;
|
|
20
|
+
}
|
|
21
|
+
/** Count one more in-flight request in the current generation. */
|
|
22
|
+
admit() {
|
|
23
|
+
const generation = this.#current;
|
|
24
|
+
generation.activeAdmissions += 1;
|
|
25
|
+
return generation;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Count a request out. The last one out of a retired generation runs the
|
|
29
|
+
* uninstall that generation owes.
|
|
30
|
+
*/
|
|
31
|
+
release(generation) {
|
|
32
|
+
generation.activeAdmissions -= 1;
|
|
33
|
+
if (generation.activeAdmissions === 0 &&
|
|
34
|
+
generation.retiredPlugins !== undefined) {
|
|
35
|
+
const plugins = generation.retiredPlugins;
|
|
36
|
+
generation.retiredPlugins = undefined;
|
|
37
|
+
this.#retired.delete(generation);
|
|
38
|
+
this.#uninstall(plugins);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Start a new generation; the previous one is returned for `retire()`. */
|
|
42
|
+
advance() {
|
|
43
|
+
const previous = this.#current;
|
|
44
|
+
this.#current = { activeAdmissions: 0 };
|
|
45
|
+
return previous;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Retire a generation that owes `plugins` an uninstall: at once when no
|
|
49
|
+
* request of it is in flight, otherwise when its last request settles.
|
|
50
|
+
*/
|
|
51
|
+
retire(generation, plugins) {
|
|
52
|
+
generation.retiredPlugins = plugins;
|
|
53
|
+
if (generation.activeAdmissions === 0) {
|
|
54
|
+
generation.retiredPlugins = undefined;
|
|
55
|
+
this.#uninstall(plugins);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
this.#retired.add(generation);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Run the uninstall a retired generation still owes this plugin object, now,
|
|
62
|
+
* before it is installed again. Left to the retired generation, it would run
|
|
63
|
+
* when that generation's last request settles — after the new install() —
|
|
64
|
+
* and tear down the live installation.
|
|
65
|
+
*/
|
|
66
|
+
uninstallBeforeReinstall(plugin) {
|
|
67
|
+
for (const generation of this.#retired) {
|
|
68
|
+
const pending = generation.retiredPlugins;
|
|
69
|
+
if (!pending?.includes(plugin))
|
|
70
|
+
continue;
|
|
71
|
+
generation.retiredPlugins = pending.filter((retired) => retired !== plugin);
|
|
72
|
+
this.#uninstall([plugin]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header names whose value is a credential, in lowercase. Debug logs replace
|
|
3
|
+
* their values with "[redacted]" (see {@link redactHeaders}) and keep the name,
|
|
4
|
+
* so a log still shows the header was sent. The CLI's admin history masks the
|
|
5
|
+
* same set.
|
|
6
|
+
*/
|
|
7
|
+
export declare const SENSITIVE_HEADER_NAMES: ReadonlySet<string>;
|
|
8
|
+
/**
|
|
9
|
+
* Look a header up by name, ignoring case. Response headers keep the casing a
|
|
10
|
+
* route gave them, so `headers["content-type"]` can miss a `Content-Type`.
|
|
11
|
+
* @returns the first matching value, or `undefined`
|
|
12
|
+
*/
|
|
13
|
+
export declare function getHeader(headers: Readonly<Record<string, string>> | undefined, name: string): string | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Whether a header of that name is present, ignoring case, whatever its value.
|
|
16
|
+
* Unlike `getHeader(...) !== undefined`, a key present with a non-string value
|
|
17
|
+
* counts, so the response normalizer still gets to reject that value.
|
|
18
|
+
*/
|
|
19
|
+
export declare function hasHeader(headers: Readonly<Record<string, unknown>>, name: string): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Replace the value of every {@link SENSITIVE_HEADER_NAMES} header with
|
|
22
|
+
* "[redacted]", matching names case-insensitively.
|
|
23
|
+
*
|
|
24
|
+
* Copy-on-write: the input is never mutated, and when nothing is sensitive the
|
|
25
|
+
* same object is returned.
|
|
26
|
+
*/
|
|
27
|
+
export declare function redactHeaders(headers: Record<string, string>): Record<string, string>;
|
package/dist/headers.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header names whose value is a credential, in lowercase. Debug logs replace
|
|
3
|
+
* their values with "[redacted]" (see {@link redactHeaders}) and keep the name,
|
|
4
|
+
* so a log still shows the header was sent. The CLI's admin history masks the
|
|
5
|
+
* same set.
|
|
6
|
+
*/
|
|
7
|
+
export const SENSITIVE_HEADER_NAMES = new Set([
|
|
8
|
+
"authorization",
|
|
9
|
+
"proxy-authorization",
|
|
10
|
+
"cookie",
|
|
11
|
+
"set-cookie",
|
|
12
|
+
"x-api-key",
|
|
13
|
+
"x-auth-token",
|
|
14
|
+
"x-schmock-admin-token",
|
|
15
|
+
]);
|
|
16
|
+
const REDACTED_HEADER_VALUE = "[redacted]";
|
|
17
|
+
/**
|
|
18
|
+
* Look a header up by name, ignoring case. Response headers keep the casing a
|
|
19
|
+
* route gave them, so `headers["content-type"]` can miss a `Content-Type`.
|
|
20
|
+
* @returns the first matching value, or `undefined`
|
|
21
|
+
*/
|
|
22
|
+
export function getHeader(headers, name) {
|
|
23
|
+
if (headers === undefined)
|
|
24
|
+
return undefined;
|
|
25
|
+
const wanted = name.toLowerCase();
|
|
26
|
+
for (const key of Object.keys(headers)) {
|
|
27
|
+
if (key.toLowerCase() === wanted)
|
|
28
|
+
return headers[key];
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Whether a header of that name is present, ignoring case, whatever its value.
|
|
34
|
+
* Unlike `getHeader(...) !== undefined`, a key present with a non-string value
|
|
35
|
+
* counts, so the response normalizer still gets to reject that value.
|
|
36
|
+
*/
|
|
37
|
+
export function hasHeader(headers, name) {
|
|
38
|
+
const wanted = name.toLowerCase();
|
|
39
|
+
return Object.keys(headers).some((key) => key.toLowerCase() === wanted);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Replace the value of every {@link SENSITIVE_HEADER_NAMES} header with
|
|
43
|
+
* "[redacted]", matching names case-insensitively.
|
|
44
|
+
*
|
|
45
|
+
* Copy-on-write: the input is never mutated, and when nothing is sensitive the
|
|
46
|
+
* same object is returned.
|
|
47
|
+
*/
|
|
48
|
+
export function redactHeaders(headers) {
|
|
49
|
+
let redacted;
|
|
50
|
+
for (const name of Object.keys(headers)) {
|
|
51
|
+
if (!SENSITIVE_HEADER_NAMES.has(name.toLowerCase()))
|
|
52
|
+
continue;
|
|
53
|
+
redacted ??= { ...headers };
|
|
54
|
+
redacted[name] = REDACTED_HEADER_VALUE;
|
|
55
|
+
}
|
|
56
|
+
return redacted ?? headers;
|
|
57
|
+
}
|
package/dist/helpers.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
export declare function notFound(message?: string | object): [
|
|
2
|
-
export declare function badRequest(message?: string | object): [
|
|
3
|
-
export declare function unauthorized(message?: string | object): [
|
|
4
|
-
export declare function forbidden(message?: string | object): [
|
|
5
|
-
export declare function serverError(message?: string | object): [
|
|
6
|
-
export declare function created(body: object): [
|
|
7
|
-
export declare function noContent(): [
|
|
1
|
+
export declare function notFound(message?: string | object): [404, object];
|
|
2
|
+
export declare function badRequest(message?: string | object): [400, object];
|
|
3
|
+
export declare function unauthorized(message?: string | object): [401, object];
|
|
4
|
+
export declare function forbidden(message?: string | object): [403, object];
|
|
5
|
+
export declare function serverError(message?: string | object): [500, object];
|
|
6
|
+
export declare function created(body: object): [201, object];
|
|
7
|
+
export declare function noContent(): [204, null];
|
|
8
8
|
/**
|
|
9
9
|
* Slice `items` into a page envelope.
|
|
10
10
|
*
|
|
@@ -12,7 +12,6 @@ export declare function noContent(): [number, null];
|
|
|
12
12
|
* page 1 and a page size of 10) so a fractional, negative, NaN or infinite
|
|
13
13
|
* option can never produce a nonsensical slice or a negative `totalPages`. The
|
|
14
14
|
* returned envelope always echoes the NORMALIZED values, so it is internally
|
|
15
|
-
* consistent with `data`.
|
|
15
|
+
* consistent with `data`. `items` is only read, so a readonly array is fine.
|
|
16
16
|
*/
|
|
17
|
-
export declare function paginate<T>(items: T[], options?: Schmock.PaginateOptions): Schmock.PaginatedResponse<T>;
|
|
18
|
-
//# sourceMappingURL=helpers.d.ts.map
|
|
17
|
+
export declare function paginate<T>(items: readonly T[], options?: Schmock.PaginateOptions): Schmock.PaginatedResponse<T>;
|
package/dist/helpers.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
/// <reference path="../schmock.d.ts" />
|
|
2
|
+
// Each helper returns its literal status (`[404, object]`, not
|
|
3
|
+
// `[number, object]`), as docs/api.md documents: a literal tuple is still
|
|
4
|
+
// assignable to `[number, object]`, and callers can destructure a typed status.
|
|
2
5
|
export function notFound(message = "Not Found") {
|
|
3
6
|
const body = typeof message === "string" ? { message } : message;
|
|
4
7
|
return [404, body];
|
|
@@ -39,7 +42,7 @@ function positiveInteger(value, fallback) {
|
|
|
39
42
|
* page 1 and a page size of 10) so a fractional, negative, NaN or infinite
|
|
40
43
|
* option can never produce a nonsensical slice or a negative `totalPages`. The
|
|
41
44
|
* returned envelope always echoes the NORMALIZED values, so it is internally
|
|
42
|
-
* consistent with `data`.
|
|
45
|
+
* consistent with `data`. `items` is only read, so a readonly array is fine.
|
|
43
46
|
*/
|
|
44
47
|
export function paginate(items, options = {}) {
|
|
45
48
|
const page = positiveInteger(options.page, 1);
|