@schmock/core 2.4.1 → 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
|
@@ -12,5 +12,40 @@ export declare function normalizeResponse(response: NormalizableResponse, method
|
|
|
12
12
|
* Encode a response body using its normalized content type semantics.
|
|
13
13
|
*/
|
|
14
14
|
export declare function serializeResponseBody(response: Schmock.Response): OwnedBytes | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Give a response the content type its body implies when it declares none:
|
|
17
|
+
* `application/octet-stream` for a binary body, `application/json` for any
|
|
18
|
+
* other non-string body (`null` included, which serializes as JSON). A string
|
|
19
|
+
* body is sent as-is and gets no default.
|
|
20
|
+
*
|
|
21
|
+
* Total: it never throws and never mutates `response`. The result is not
|
|
22
|
+
* normalized; pass it to `normalizeResponse` when it still needs to be.
|
|
23
|
+
*/
|
|
24
|
+
export declare function withDefaultContentType(response: Schmock.Response): Schmock.Response;
|
|
25
|
+
/**
|
|
26
|
+
* The JSON error envelope `{ "error": message, "code": code }` every transport
|
|
27
|
+
* answers a failure with, normalized for `method`. `headers` are added after
|
|
28
|
+
* the JSON content type (a 405's `allow`). The one constructor of that shape,
|
|
29
|
+
* so `handle()`, `listen()` and `intercept()` cannot drift apart.
|
|
30
|
+
*/
|
|
31
|
+
export declare function buildJsonErrorResponse(input: {
|
|
32
|
+
status: number;
|
|
33
|
+
error: string;
|
|
34
|
+
code: string;
|
|
35
|
+
method: string;
|
|
36
|
+
headers?: Readonly<Record<string, string>>;
|
|
37
|
+
}): Schmock.Response;
|
|
38
|
+
/**
|
|
39
|
+
* Run an `errorFormatter` and build the normalized 500 that carries its result.
|
|
40
|
+
*
|
|
41
|
+
* Total: it never throws, and the formatter runs exactly once. There are two
|
|
42
|
+
* fallbacks. When the inherited headers cannot be sent (a non-string value, a
|
|
43
|
+
* control character, a case-duplicate name), the formatted body is kept and
|
|
44
|
+
* sent with the fixed JSON header set instead, since losing the body would
|
|
45
|
+
* silently change the caller's error contract. When the formatter throws or
|
|
46
|
+
* its result cannot be serialized, the minimal
|
|
47
|
+
* `{ error: "Internal Server Error", code: "INTERNAL_ERROR" }` body is sent,
|
|
48
|
+
* inheriting nothing.
|
|
49
|
+
*/
|
|
50
|
+
export declare function buildFormattedErrorResponse(options: Schmock.FormattedErrorOptions): Schmock.Response;
|
|
15
51
|
export {};
|
|
16
|
-
//# sourceMappingURL=response-normalizer.d.ts.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isBinaryBody } from "./binary.js";
|
|
2
2
|
import { errorMessage, InvalidResponseError } from "./errors.js";
|
|
3
|
+
import { hasHeader } from "./headers.js";
|
|
3
4
|
const BODY_FORBIDDEN_STATUSES = new Set([204, 205, 304]);
|
|
4
5
|
const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
5
6
|
const FRAMING_HEADERS = new Set([
|
|
@@ -314,3 +315,104 @@ export function serializeResponseBody(response) {
|
|
|
314
315
|
const serialized = typeof body === "string" ? body : stringifyJsonBody(body);
|
|
315
316
|
return new TextEncoder().encode(serialized);
|
|
316
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* Give a response the content type its body implies when it declares none:
|
|
320
|
+
* `application/octet-stream` for a binary body, `application/json` for any
|
|
321
|
+
* other non-string body (`null` included, which serializes as JSON). A string
|
|
322
|
+
* body is sent as-is and gets no default.
|
|
323
|
+
*
|
|
324
|
+
* Total: it never throws and never mutates `response`. The result is not
|
|
325
|
+
* normalized; pass it to `normalizeResponse` when it still needs to be.
|
|
326
|
+
*/
|
|
327
|
+
export function withDefaultContentType(response) {
|
|
328
|
+
const headers = { ...response.headers };
|
|
329
|
+
const body = response.body;
|
|
330
|
+
if (body !== undefined && !hasHeader(headers, "content-type")) {
|
|
331
|
+
if (isBinaryBody(body)) {
|
|
332
|
+
headers["content-type"] = "application/octet-stream";
|
|
333
|
+
}
|
|
334
|
+
else if (typeof body !== "string") {
|
|
335
|
+
headers["content-type"] = "application/json";
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return { status: response.status, body, headers };
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* A formatted error body is always JSON, whatever the replaced response
|
|
342
|
+
* declared. Every case variant of content-type is dropped first: a leftover
|
|
343
|
+
* `Content-Type` beside the lowercase key makes the pair untransportable.
|
|
344
|
+
*/
|
|
345
|
+
function withJsonContentType(headers) {
|
|
346
|
+
const result = {};
|
|
347
|
+
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
348
|
+
if (name.toLowerCase() === "content-type")
|
|
349
|
+
continue;
|
|
350
|
+
result[name] = value;
|
|
351
|
+
}
|
|
352
|
+
result["content-type"] = "application/json";
|
|
353
|
+
return result;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* The JSON error envelope `{ "error": message, "code": code }` every transport
|
|
357
|
+
* answers a failure with, normalized for `method`. `headers` are added after
|
|
358
|
+
* the JSON content type (a 405's `allow`). The one constructor of that shape,
|
|
359
|
+
* so `handle()`, `listen()` and `intercept()` cannot drift apart.
|
|
360
|
+
*/
|
|
361
|
+
export function buildJsonErrorResponse(input) {
|
|
362
|
+
return normalizeResponse({
|
|
363
|
+
status: input.status,
|
|
364
|
+
body: { error: input.error, code: input.code },
|
|
365
|
+
headers: { "content-type": "application/json", ...input.headers },
|
|
366
|
+
}, input.method);
|
|
367
|
+
}
|
|
368
|
+
function internalErrorResponse(method) {
|
|
369
|
+
return buildJsonErrorResponse({
|
|
370
|
+
status: 500,
|
|
371
|
+
error: "Internal Server Error",
|
|
372
|
+
code: "INTERNAL_ERROR",
|
|
373
|
+
method,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Run an `errorFormatter` and build the normalized 500 that carries its result.
|
|
378
|
+
*
|
|
379
|
+
* Total: it never throws, and the formatter runs exactly once. There are two
|
|
380
|
+
* fallbacks. When the inherited headers cannot be sent (a non-string value, a
|
|
381
|
+
* control character, a case-duplicate name), the formatted body is kept and
|
|
382
|
+
* sent with the fixed JSON header set instead, since losing the body would
|
|
383
|
+
* silently change the caller's error contract. When the formatter throws or
|
|
384
|
+
* its result cannot be serialized, the minimal
|
|
385
|
+
* `{ error: "Internal Server Error", code: "INTERNAL_ERROR" }` body is sent,
|
|
386
|
+
* inheriting nothing.
|
|
387
|
+
*/
|
|
388
|
+
export function buildFormattedErrorResponse(options) {
|
|
389
|
+
const { formatter, error, inheritedHeaders, method } = options;
|
|
390
|
+
let formatted;
|
|
391
|
+
try {
|
|
392
|
+
formatted = formatter(error);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return internalErrorResponse(method);
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
return normalizeResponse({
|
|
399
|
+
status: 500,
|
|
400
|
+
body: formatted,
|
|
401
|
+
headers: withJsonContentType(inheritedHeaders),
|
|
402
|
+
}, method);
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
// The inherited headers were not transportable. `formatted` is reused,
|
|
406
|
+
// so the formatter still fires exactly once.
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
return normalizeResponse({
|
|
410
|
+
status: 500,
|
|
411
|
+
body: formatted,
|
|
412
|
+
headers: { "content-type": "application/json" },
|
|
413
|
+
}, method);
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return internalErrorResponse(method);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -1,6 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split a route or plugin result into the status, body and headers core will
|
|
3
|
+
* answer with, using exactly the guards `handle()` applies: an object is an
|
|
4
|
+
* envelope only when it has a numeric `status`, a `body`, and `headers` that
|
|
5
|
+
* are absent or a string record; anything else is delivered whole as the body.
|
|
6
|
+
*
|
|
7
|
+
* `body` is the element as carried (`null` stays `null`, though core sends no
|
|
8
|
+
* body for it), `status` is what core answers with (a plain `null` or
|
|
9
|
+
* `undefined` result is 204), and `headers` is a fresh copy, `{}` when the
|
|
10
|
+
* carried headers are not a string record.
|
|
11
|
+
*/
|
|
12
|
+
export declare function getResponseParts(response: unknown): Schmock.ResponseParts;
|
|
13
|
+
/**
|
|
14
|
+
* Put `body` in place of the body `response` carries, keeping its shape: a
|
|
15
|
+
* tuple stays a tuple of the same length, an envelope keeps its status and
|
|
16
|
+
* headers (other properties are dropped, as core ignores them), and a plain
|
|
17
|
+
* result is replaced by `body` itself. Never mutates `response`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function replaceResponseBody(response: unknown, body: unknown): unknown;
|
|
1
20
|
/**
|
|
2
21
|
* Parse and normalize response result into Response object
|
|
3
22
|
* Handles tuple format [status, body, headers], direct values, and response objects
|
|
4
23
|
*/
|
|
5
24
|
export declare function parseResponse(result: unknown, routeConfig: Schmock.RouteConfig): Schmock.Response;
|
|
6
|
-
//# sourceMappingURL=response-parser.d.ts.map
|
package/dist/response-parser.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isBinaryBody } from "./binary.js";
|
|
2
2
|
import { isStatusTuple } from "./constants.js";
|
|
3
3
|
import { InvalidResponseError } from "./errors.js";
|
|
4
|
+
import { hasHeader } from "./headers.js";
|
|
4
5
|
const BINARY_CONTENT_TYPE = "application/octet-stream";
|
|
5
6
|
/**
|
|
6
7
|
* Take ownership of caller-supplied response headers.
|
|
@@ -25,7 +26,7 @@ function toOwnHeaderRecord(value) {
|
|
|
25
26
|
return record;
|
|
26
27
|
}
|
|
27
28
|
function hasContentType(headers) {
|
|
28
|
-
return
|
|
29
|
+
return hasHeader(headers, "content-type");
|
|
29
30
|
}
|
|
30
31
|
/**
|
|
31
32
|
* Detect the object response envelope `{ status, body, headers? }`.
|
|
@@ -35,8 +36,9 @@ function hasContentType(headers) {
|
|
|
35
36
|
* payload. Callers who need to return such a shape as data should nest it or
|
|
36
37
|
* use an explicit `[status, body]` tuple for the envelope. An object whose
|
|
37
38
|
* `headers` is present but not a string record is deliberately NOT an envelope
|
|
38
|
-
* and is delivered whole — plugins that inspect responses
|
|
39
|
-
*
|
|
39
|
+
* and is delivered whole — plugins that inspect responses read them through
|
|
40
|
+
* {@link getResponseParts}, which applies this same rule, or they will judge
|
|
41
|
+
* an undelivered payload.
|
|
40
42
|
*/
|
|
41
43
|
function isResponseObject(value) {
|
|
42
44
|
return (typeof value === "object" &&
|
|
@@ -56,27 +58,83 @@ function isStringRecord(value) {
|
|
|
56
58
|
Object.values(value).every((entry) => typeof entry === "string"));
|
|
57
59
|
}
|
|
58
60
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
+
* The single place a route result is split into status, body and headers.
|
|
62
|
+
* `parseResponse` and the exported `getResponseParts` both build on it, so
|
|
63
|
+
* what a plugin inspects is what core delivers.
|
|
61
64
|
*/
|
|
62
|
-
|
|
63
|
-
let status = 200;
|
|
64
|
-
let body = result;
|
|
65
|
-
let headers = {};
|
|
66
|
-
let tupleFormat = false;
|
|
65
|
+
function decomposeResponse(result) {
|
|
67
66
|
// Handle already-formed response objects (from plugin error recovery)
|
|
68
67
|
if (isResponseObject(result)) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
return {
|
|
69
|
+
kind: "object",
|
|
70
|
+
status: result.status,
|
|
71
|
+
body: result.body,
|
|
72
|
+
rawHeaders: result.headers,
|
|
73
|
+
};
|
|
73
74
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
// Handle tuple response format [status, body, headers?]
|
|
76
|
+
if (isStatusTuple(result)) {
|
|
77
|
+
return {
|
|
78
|
+
kind: "tuple",
|
|
79
|
+
status: result[0],
|
|
80
|
+
body: result[1],
|
|
81
|
+
rawHeaders: result[2],
|
|
82
|
+
};
|
|
79
83
|
}
|
|
84
|
+
return { kind: "plain", status: 200, body: result, rawHeaders: undefined };
|
|
85
|
+
}
|
|
86
|
+
function isNullish(value) {
|
|
87
|
+
return value === null || value === undefined;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Split a route or plugin result into the status, body and headers core will
|
|
91
|
+
* answer with, using exactly the guards `handle()` applies: an object is an
|
|
92
|
+
* envelope only when it has a numeric `status`, a `body`, and `headers` that
|
|
93
|
+
* are absent or a string record; anything else is delivered whole as the body.
|
|
94
|
+
*
|
|
95
|
+
* `body` is the element as carried (`null` stays `null`, though core sends no
|
|
96
|
+
* body for it), `status` is what core answers with (a plain `null` or
|
|
97
|
+
* `undefined` result is 204), and `headers` is a fresh copy, `{}` when the
|
|
98
|
+
* carried headers are not a string record.
|
|
99
|
+
*/
|
|
100
|
+
export function getResponseParts(response) {
|
|
101
|
+
const parts = decomposeResponse(response);
|
|
102
|
+
return {
|
|
103
|
+
kind: parts.kind,
|
|
104
|
+
status: parts.kind === "plain" && isNullish(parts.body) ? 204 : parts.status,
|
|
105
|
+
body: parts.body,
|
|
106
|
+
headers: isStringRecord(parts.rawHeaders) ? { ...parts.rawHeaders } : {},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Put `body` in place of the body `response` carries, keeping its shape: a
|
|
111
|
+
* tuple stays a tuple of the same length, an envelope keeps its status and
|
|
112
|
+
* headers (other properties are dropped, as core ignores them), and a plain
|
|
113
|
+
* result is replaced by `body` itself. Never mutates `response`.
|
|
114
|
+
*/
|
|
115
|
+
export function replaceResponseBody(response, body) {
|
|
116
|
+
if (isResponseObject(response)) {
|
|
117
|
+
return response.headers === undefined
|
|
118
|
+
? { status: response.status, body }
|
|
119
|
+
: { status: response.status, body, headers: response.headers };
|
|
120
|
+
}
|
|
121
|
+
if (isStatusTuple(response)) {
|
|
122
|
+
return response.length === 3
|
|
123
|
+
? [response[0], body, response[2]]
|
|
124
|
+
: [response[0], body];
|
|
125
|
+
}
|
|
126
|
+
return body;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Parse and normalize response result into Response object
|
|
130
|
+
* Handles tuple format [status, body, headers], direct values, and response objects
|
|
131
|
+
*/
|
|
132
|
+
export function parseResponse(result, routeConfig) {
|
|
133
|
+
const parts = decomposeResponse(result);
|
|
134
|
+
let status = parts.status;
|
|
135
|
+
let body = parts.body;
|
|
136
|
+
const headers = toOwnHeaderRecord(parts.rawHeaders);
|
|
137
|
+
const tupleFormat = parts.kind !== "plain";
|
|
80
138
|
// Handle null/undefined responses with 204 No Content
|
|
81
139
|
// But don't auto-convert if tuple format was used (status was explicitly provided)
|
|
82
140
|
if (body === null || body === undefined) {
|
package/dist/route-matcher.d.ts
CHANGED
|
@@ -24,4 +24,3 @@ export declare function findRoute(method: Schmock.HttpMethod, path: string, stat
|
|
|
24
24
|
* captures are decoded afterwards, so a generator receives readable values.
|
|
25
25
|
*/
|
|
26
26
|
export declare function extractParams(route: CompiledCallableRoute, path: string): Record<string, string>;
|
|
27
|
-
//# sourceMappingURL=route-matcher.d.ts.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { DebugLogger } from "./debug-logger.js";
|
|
2
|
+
import type { CompiledCallableRoute } from "./route-matcher.js";
|
|
3
|
+
/** The route containers an admitted request routes with. */
|
|
4
|
+
export interface RouteTableSnapshot {
|
|
5
|
+
readonly routes: CompiledCallableRoute[];
|
|
6
|
+
readonly staticRoutes: Map<string, CompiledCallableRoute>;
|
|
7
|
+
}
|
|
8
|
+
/** The table as it stood before a `pipe()` install, for its rollback. */
|
|
9
|
+
interface RouteTableCheckpoint extends RouteTableSnapshot {
|
|
10
|
+
readonly shared: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The mock's registered routes: every route in registration order, plus the
|
|
14
|
+
* static (parameterless) ones in a map for O(1) lookup.
|
|
15
|
+
*
|
|
16
|
+
* The containers are copy-on-write. An admitted request captures them by
|
|
17
|
+
* reference (`share()`), and the first registration after that copies them,
|
|
18
|
+
* so an in-flight snapshot never changes underneath its request and no
|
|
19
|
+
* request pays for a copy of the whole table.
|
|
20
|
+
*/
|
|
21
|
+
export declare class RouteTable {
|
|
22
|
+
#private;
|
|
23
|
+
/** O(1) snapshot for an admitted request. */
|
|
24
|
+
share(): RouteTableSnapshot;
|
|
25
|
+
define(input: {
|
|
26
|
+
route: Schmock.RouteKey;
|
|
27
|
+
generator: Schmock.Generator;
|
|
28
|
+
config: Schmock.RouteConfig;
|
|
29
|
+
logger: DebugLogger;
|
|
30
|
+
}): void;
|
|
31
|
+
list(): Schmock.RouteInfo[];
|
|
32
|
+
/**
|
|
33
|
+
* Start a transaction: later registrations go into fresh copies, and
|
|
34
|
+
* `rollback()` puts the table back exactly as it was.
|
|
35
|
+
*/
|
|
36
|
+
checkpoint(): RouteTableCheckpoint;
|
|
37
|
+
rollback(checkpoint: RouteTableCheckpoint): void;
|
|
38
|
+
/**
|
|
39
|
+
* Empty the table. The containers are replaced, never cleared in place:
|
|
40
|
+
* in-flight admissions still route with the old ones.
|
|
41
|
+
*/
|
|
42
|
+
clear(): void;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A per-request copy of a route's config for the plugin context.
|
|
46
|
+
*
|
|
47
|
+
* Shallow on purpose: plugin metadata under `openapi:*` keys is shared,
|
|
48
|
+
* read-only structure (and some of it is keyed by identity in WeakMaps), but a
|
|
49
|
+
* plugin that assigns `context.route.contentType` or edits the delay tuple must
|
|
50
|
+
* not change every later request.
|
|
51
|
+
*/
|
|
52
|
+
export declare function copyRouteConfig(config: Schmock.RouteConfig): Schmock.RouteConfig;
|
|
53
|
+
/**
|
|
54
|
+
* Deep-copy the plain data (arrays and plain objects) of a static generator so
|
|
55
|
+
* plugins can edit their response in place without changing the route.
|
|
56
|
+
*
|
|
57
|
+
* Anything else — dates, binary values, class instances with a prototype
|
|
58
|
+
* `toJSON` — is passed by reference: `structuredClone` would strip those
|
|
59
|
+
* prototypes (a Buffer would come back as a bare Uint8Array) and change what
|
|
60
|
+
* the response serializes to. Enumerable symbol keys are kept so the response
|
|
61
|
+
* normalizer still sees, and rejects, them.
|
|
62
|
+
*/
|
|
63
|
+
export declare function copyStaticData(value: unknown, copies?: Map<object, unknown>): unknown;
|
|
64
|
+
export {};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { isBinaryBody } from "./binary.js";
|
|
2
|
+
import { normalizePath } from "./constants.js";
|
|
3
|
+
import { RouteDefinitionError } from "./errors.js";
|
|
4
|
+
import { parseRouteKey } from "./parser.js";
|
|
5
|
+
function defaultContentType(generator) {
|
|
6
|
+
if (typeof generator === "function") {
|
|
7
|
+
// Default to JSON for function generators
|
|
8
|
+
return "application/json";
|
|
9
|
+
}
|
|
10
|
+
if (typeof generator === "string" ||
|
|
11
|
+
typeof generator === "number" ||
|
|
12
|
+
typeof generator === "boolean") {
|
|
13
|
+
// Default to plain text for primitives
|
|
14
|
+
return "text/plain";
|
|
15
|
+
}
|
|
16
|
+
if (isBinaryBody(generator)) {
|
|
17
|
+
// Default to octet-stream for browser and Node binary values
|
|
18
|
+
return "application/octet-stream";
|
|
19
|
+
}
|
|
20
|
+
// Default to JSON for objects/arrays
|
|
21
|
+
return "application/json";
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The mock's registered routes: every route in registration order, plus the
|
|
25
|
+
* static (parameterless) ones in a map for O(1) lookup.
|
|
26
|
+
*
|
|
27
|
+
* The containers are copy-on-write. An admitted request captures them by
|
|
28
|
+
* reference (`share()`), and the first registration after that copies them,
|
|
29
|
+
* so an in-flight snapshot never changes underneath its request and no
|
|
30
|
+
* request pays for a copy of the whole table.
|
|
31
|
+
*/
|
|
32
|
+
export class RouteTable {
|
|
33
|
+
#routes = [];
|
|
34
|
+
#staticRoutes = new Map();
|
|
35
|
+
/** True once an admission holds the containers by reference. */
|
|
36
|
+
#shared = false;
|
|
37
|
+
/** O(1) snapshot for an admitted request. */
|
|
38
|
+
share() {
|
|
39
|
+
this.#shared = true;
|
|
40
|
+
return { routes: this.#routes, staticRoutes: this.#staticRoutes };
|
|
41
|
+
}
|
|
42
|
+
define(input) {
|
|
43
|
+
const { route, generator, logger } = input;
|
|
44
|
+
// FIX 1.2: shallow-clone the caller's config so mutations below stay private
|
|
45
|
+
const routeConfig = { ...input.config };
|
|
46
|
+
// Auto-detect contentType if not provided
|
|
47
|
+
if (!routeConfig.contentType) {
|
|
48
|
+
routeConfig.contentType = defaultContentType(generator);
|
|
49
|
+
}
|
|
50
|
+
// Validate generator matches contentType if it's static data
|
|
51
|
+
if (typeof generator !== "function" &&
|
|
52
|
+
routeConfig.contentType === "application/json") {
|
|
53
|
+
try {
|
|
54
|
+
JSON.stringify(generator);
|
|
55
|
+
}
|
|
56
|
+
catch (_error) {
|
|
57
|
+
throw new RouteDefinitionError(route, "Generator data is not valid JSON but contentType is application/json");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Parse the route key to create pattern and extract parameters
|
|
61
|
+
const parsed = parseRouteKey(route);
|
|
62
|
+
// FIX 2.2: normalize paths before duplicate check so /users and /users/ are
|
|
63
|
+
// treated as the same route (consistent with the static-route Map key below).
|
|
64
|
+
// Routes that differ only in parameter names (`/users/:id` and
|
|
65
|
+
// `/users/:userId`) compile to the same pattern and match the same
|
|
66
|
+
// requests, so the later one would be unreachable: it is a duplicate too.
|
|
67
|
+
const normalizedParsedPath = normalizePath(parsed.path);
|
|
68
|
+
const existing = this.#routes.find((r) => r.method === parsed.method &&
|
|
69
|
+
(normalizePath(r.path) === normalizedParsedPath ||
|
|
70
|
+
r.pattern.source === parsed.pattern.source));
|
|
71
|
+
if (existing) {
|
|
72
|
+
logger.log("warning", normalizePath(existing.path) === normalizedParsedPath
|
|
73
|
+
? `Duplicate route: ${route} — first registration wins`
|
|
74
|
+
: `Duplicate route: ${route} matches the same requests as ${existing.method} ${existing.path} — first registration wins`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// Compile the route
|
|
78
|
+
const compiledRoute = {
|
|
79
|
+
pattern: parsed.pattern,
|
|
80
|
+
params: parsed.params,
|
|
81
|
+
method: parsed.method,
|
|
82
|
+
path: parsed.path,
|
|
83
|
+
generator,
|
|
84
|
+
config: routeConfig,
|
|
85
|
+
};
|
|
86
|
+
this.#writable();
|
|
87
|
+
this.#routes.push(compiledRoute);
|
|
88
|
+
// Store static routes (no params) in Map for O(1) lookup
|
|
89
|
+
if (parsed.params.length === 0) {
|
|
90
|
+
const key = `${parsed.method} ${normalizePath(parsed.path)}`;
|
|
91
|
+
this.#staticRoutes.set(key, compiledRoute);
|
|
92
|
+
}
|
|
93
|
+
logger.log("route", `Route defined: ${route}`, {
|
|
94
|
+
contentType: routeConfig.contentType,
|
|
95
|
+
generatorType: typeof generator,
|
|
96
|
+
hasParams: parsed.params.length > 0,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
list() {
|
|
100
|
+
return this.#routes.map((r) => ({
|
|
101
|
+
method: r.method,
|
|
102
|
+
path: r.path,
|
|
103
|
+
hasParams: r.params.length > 0,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Start a transaction: later registrations go into fresh copies, and
|
|
108
|
+
* `rollback()` puts the table back exactly as it was.
|
|
109
|
+
*/
|
|
110
|
+
checkpoint() {
|
|
111
|
+
const checkpoint = {
|
|
112
|
+
routes: this.#routes,
|
|
113
|
+
staticRoutes: this.#staticRoutes,
|
|
114
|
+
shared: this.#shared,
|
|
115
|
+
};
|
|
116
|
+
this.#routes = checkpoint.routes.slice();
|
|
117
|
+
this.#staticRoutes = new Map(checkpoint.staticRoutes);
|
|
118
|
+
this.#shared = false;
|
|
119
|
+
return checkpoint;
|
|
120
|
+
}
|
|
121
|
+
rollback(checkpoint) {
|
|
122
|
+
this.#routes = checkpoint.routes;
|
|
123
|
+
this.#staticRoutes = checkpoint.staticRoutes;
|
|
124
|
+
this.#shared = checkpoint.shared;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Empty the table. The containers are replaced, never cleared in place:
|
|
128
|
+
* in-flight admissions still route with the old ones.
|
|
129
|
+
*/
|
|
130
|
+
clear() {
|
|
131
|
+
this.#routes = [];
|
|
132
|
+
this.#staticRoutes = new Map();
|
|
133
|
+
this.#shared = false;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Copy-on-write for the route tables. An admitted request routes with the
|
|
137
|
+
* containers it captured, by reference; the first registration after that
|
|
138
|
+
* copies them so the in-flight snapshot never changes underneath it.
|
|
139
|
+
*/
|
|
140
|
+
#writable() {
|
|
141
|
+
if (!this.#shared)
|
|
142
|
+
return;
|
|
143
|
+
this.#routes = this.#routes.slice();
|
|
144
|
+
this.#staticRoutes = new Map(this.#staticRoutes);
|
|
145
|
+
this.#shared = false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* A per-request copy of a route's config for the plugin context.
|
|
150
|
+
*
|
|
151
|
+
* Shallow on purpose: plugin metadata under `openapi:*` keys is shared,
|
|
152
|
+
* read-only structure (and some of it is keyed by identity in WeakMaps), but a
|
|
153
|
+
* plugin that assigns `context.route.contentType` or edits the delay tuple must
|
|
154
|
+
* not change every later request.
|
|
155
|
+
*/
|
|
156
|
+
export function copyRouteConfig(config) {
|
|
157
|
+
const copy = { ...config };
|
|
158
|
+
if (Array.isArray(config.delay)) {
|
|
159
|
+
copy.delay = [config.delay[0], config.delay[1]];
|
|
160
|
+
}
|
|
161
|
+
return copy;
|
|
162
|
+
}
|
|
163
|
+
function isPlainObject(value) {
|
|
164
|
+
const prototype = Object.getPrototypeOf(value);
|
|
165
|
+
return prototype === Object.prototype || prototype === null;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Define an own enumerable data property. Plain assignment would run the
|
|
169
|
+
* `__proto__` setter for an own `__proto__` key (which `JSON.parse` creates),
|
|
170
|
+
* dropping the key and re-prototyping the copy.
|
|
171
|
+
*/
|
|
172
|
+
function defineDataProperty(target, key, value) {
|
|
173
|
+
Object.defineProperty(target, key, {
|
|
174
|
+
value,
|
|
175
|
+
enumerable: true,
|
|
176
|
+
writable: true,
|
|
177
|
+
configurable: true,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Deep-copy the plain data (arrays and plain objects) of a static generator so
|
|
182
|
+
* plugins can edit their response in place without changing the route.
|
|
183
|
+
*
|
|
184
|
+
* Anything else — dates, binary values, class instances with a prototype
|
|
185
|
+
* `toJSON` — is passed by reference: `structuredClone` would strip those
|
|
186
|
+
* prototypes (a Buffer would come back as a bare Uint8Array) and change what
|
|
187
|
+
* the response serializes to. Enumerable symbol keys are kept so the response
|
|
188
|
+
* normalizer still sees, and rejects, them.
|
|
189
|
+
*/
|
|
190
|
+
export function copyStaticData(value, copies = new Map()) {
|
|
191
|
+
if (typeof value !== "object" || value === null)
|
|
192
|
+
return value;
|
|
193
|
+
const existing = copies.get(value);
|
|
194
|
+
if (existing !== undefined)
|
|
195
|
+
return existing;
|
|
196
|
+
if (Array.isArray(value)) {
|
|
197
|
+
const copy = new Array(value.length);
|
|
198
|
+
copies.set(value, copy);
|
|
199
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
200
|
+
if (index in value)
|
|
201
|
+
copy[index] = copyStaticData(value[index], copies);
|
|
202
|
+
}
|
|
203
|
+
for (const key of Object.getOwnPropertySymbols(value)) {
|
|
204
|
+
if (!Object.getOwnPropertyDescriptor(value, key)?.enumerable)
|
|
205
|
+
continue;
|
|
206
|
+
defineDataProperty(copy, key, copyStaticData(Reflect.get(value, key), copies));
|
|
207
|
+
}
|
|
208
|
+
return copy;
|
|
209
|
+
}
|
|
210
|
+
if (!isPlainObject(value))
|
|
211
|
+
return value;
|
|
212
|
+
const copy = Object.getPrototypeOf(value) === null ? Object.create(null) : {};
|
|
213
|
+
copies.set(value, copy);
|
|
214
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
215
|
+
if (!Object.getOwnPropertyDescriptor(value, key)?.enumerable)
|
|
216
|
+
continue;
|
|
217
|
+
defineDataProperty(copy, key, copyStaticData(Reflect.get(value, key), copies));
|
|
218
|
+
}
|
|
219
|
+
return copy;
|
|
220
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export type CallableMockInstance = Schmock.CallableMockInstance;
|
|
|
15
15
|
export type Plugin = Schmock.Plugin;
|
|
16
16
|
export type PluginContext = Schmock.PluginContext;
|
|
17
17
|
export type PluginResult = Schmock.PluginResult;
|
|
18
|
+
export type PluginHookResult = Schmock.PluginHookResult;
|
|
18
19
|
export type StaticData = Schmock.StaticData;
|
|
19
20
|
export type RequestRecord = Schmock.RequestRecord;
|
|
20
21
|
export type ServerInfo = Schmock.ServerInfo;
|
|
@@ -24,9 +25,22 @@ export type ResponseHeaderDef = Schmock.ResponseHeaderDef;
|
|
|
24
25
|
export type CrudOperationMeta = Schmock.CrudOperationMeta;
|
|
25
26
|
export type SchemaGenerationContext = Schmock.SchemaGenerationContext;
|
|
26
27
|
export type FakerPluginOptions = Schmock.FakerPluginOptions;
|
|
28
|
+
/**
|
|
29
|
+
* @deprecated Import `ExpressAdapterOptions` from `@schmock/express`, which
|
|
30
|
+
* types `req`/`res` as Express's `Request`/`Response`. This copy types them
|
|
31
|
+
* `unknown` and will be removed in the next major version.
|
|
32
|
+
*/
|
|
27
33
|
export type ExpressAdapterOptions = Schmock.ExpressAdapterOptions;
|
|
34
|
+
/**
|
|
35
|
+
* @deprecated Import `AngularAdapterOptions` from `@schmock/angular`, which
|
|
36
|
+
* types `request` as Angular's `HttpRequest`. This copy types it `unknown` and
|
|
37
|
+
* will be removed in the next major version.
|
|
38
|
+
*/
|
|
28
39
|
export type AngularAdapterOptions = Schmock.AngularAdapterOptions;
|
|
29
40
|
export type OpenApiOptions = Schmock.OpenApiOptions;
|
|
41
|
+
export type OpenApiRefPolicy = Schmock.OpenApiRefPolicy;
|
|
42
|
+
export type OnSchemaContext = Schmock.OnSchemaContext;
|
|
43
|
+
export type OnSchemaCallback = Schmock.OnSchemaCallback;
|
|
30
44
|
export type OpenApiCallbackRequest = Schmock.OpenApiCallbackRequest;
|
|
31
45
|
export type OpenApiCallbackOptions = Schmock.OpenApiCallbackOptions;
|
|
32
46
|
export type SeedSource = Schmock.SeedSource;
|
|
@@ -36,4 +50,16 @@ export type AdapterResponse = Schmock.AdapterResponse;
|
|
|
36
50
|
export type InterceptOptions = Schmock.InterceptOptions;
|
|
37
51
|
export type InterceptHandle = Schmock.InterceptHandle;
|
|
38
52
|
export type AdapterRequestOverride = Schmock.AdapterRequestOverride;
|
|
39
|
-
|
|
53
|
+
export type PaginateOptions = Schmock.PaginateOptions;
|
|
54
|
+
export type PaginatedResponse<T> = Schmock.PaginatedResponse<T>;
|
|
55
|
+
export type RequestStartEvent = Schmock.RequestStartEvent;
|
|
56
|
+
export type RequestMatchEvent = Schmock.RequestMatchEvent;
|
|
57
|
+
export type RequestNotFoundEvent = Schmock.RequestNotFoundEvent;
|
|
58
|
+
export type RequestEndEvent = Schmock.RequestEndEvent;
|
|
59
|
+
export type SchmockEventMap = Schmock.SchmockEventMap;
|
|
60
|
+
export type SchmockEvent = Schmock.SchmockEvent;
|
|
61
|
+
export type ResponseParts = Schmock.ResponseParts;
|
|
62
|
+
export type PathPrefix = Schmock.PathPrefix;
|
|
63
|
+
export type FormattedErrorOptions = Schmock.FormattedErrorOptions;
|
|
64
|
+
export type MockRequestHandler = Schmock.MockRequestHandler;
|
|
65
|
+
export type RequestAdmission = Schmock.RequestAdmission;
|
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.5.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
"import": "./dist/index.js",
|
|
28
28
|
"default": "./dist/index.js"
|
|
29
29
|
},
|
|
30
|
+
"./adapter": {
|
|
31
|
+
"types": "./dist/adapter.d.ts",
|
|
32
|
+
"import": "./dist/adapter.js",
|
|
33
|
+
"default": "./dist/adapter.js"
|
|
34
|
+
},
|
|
30
35
|
"./package.json": "./package.json"
|
|
31
36
|
},
|
|
32
37
|
"scripts": {
|
|
@@ -39,8 +44,8 @@
|
|
|
39
44
|
"test:watch": "vitest --watch",
|
|
40
45
|
"pretest:bdd": "rm -f src/*.js src/*.d.ts || true",
|
|
41
46
|
"test:bdd": "vitest run --config vitest.config.bdd.ts",
|
|
42
|
-
"lint": "biome check src
|
|
43
|
-
"lint:fix": "biome check --write --unsafe src
|
|
47
|
+
"lint": "biome check src/",
|
|
48
|
+
"lint:fix": "biome check --write --unsafe src/",
|
|
44
49
|
"check:publish": "publint && attw --pack --ignore-rules cjs-resolves-to-esm"
|
|
45
50
|
},
|
|
46
51
|
"license": "MIT",
|