@schmock/core 2.4.1 → 2.6.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 +21 -0
- package/dist/adapter.js +19 -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 +425 -904
- 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 +48 -0
- package/dist/generations.js +82 -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 +151 -0
- package/dist/http-helpers.d.ts +110 -5
- package/dist/http-helpers.js +328 -46
- package/dist/index.d.ts +295 -31
- package/dist/index.js +17 -9
- package/dist/interceptor.d.ts +40 -10
- package/dist/interceptor.js +412 -178
- 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 +57 -0
- package/dist/plugin-hooks.js +276 -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/snapshot.d.ts +14 -0
- package/dist/snapshot.js +98 -0
- package/dist/types.d.ts +34 -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
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { errorMessage, SchmockError } from "./errors.js";
|
|
2
|
+
import { snapshotNormalizedBody, snapshotRequestBody } from "./snapshot.js";
|
|
3
|
+
const PLUGIN_HOOK_ERROR_CODES = {
|
|
4
|
+
install: {
|
|
5
|
+
expired: "PLUGIN_INSTALL_SCOPE_EXPIRED",
|
|
6
|
+
unsupported: "PLUGIN_INSTALL_OPERATION_UNSUPPORTED",
|
|
7
|
+
},
|
|
8
|
+
uninstall: {
|
|
9
|
+
expired: "PLUGIN_UNINSTALL_SCOPE_EXPIRED",
|
|
10
|
+
unsupported: "PLUGIN_UNINSTALL_OPERATION_UNSUPPORTED",
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Optional hooks rejected when set to a truthy non-function. `install` threw a
|
|
15
|
+
* TypeError from pipe() and `beforeRequest` failed every matched request, so
|
|
16
|
+
* rejecting them breaks no working setup. `onExchange` is new: no working
|
|
17
|
+
* setup relies on another shape, and a non-function would otherwise silently
|
|
18
|
+
* observe nothing. Falsy values (`onError: null`, `install: false`,
|
|
19
|
+
* `onExchange: undefined`) still switch a hook off. `onError`/`uninstall`
|
|
20
|
+
* failures only ever surfaced on paths that already fail or log, so they are
|
|
21
|
+
* left alone.
|
|
22
|
+
*/
|
|
23
|
+
const EAGER_PLUGIN_HOOKS = ["install", "beforeRequest", "onExchange"];
|
|
24
|
+
export function isThenable(value) {
|
|
25
|
+
return (typeof value === "object" &&
|
|
26
|
+
value !== null &&
|
|
27
|
+
"then" in value &&
|
|
28
|
+
typeof value.then === "function");
|
|
29
|
+
}
|
|
30
|
+
function describeInvalidPlugin(plugin) {
|
|
31
|
+
if ((typeof plugin !== "object" && typeof plugin !== "function") ||
|
|
32
|
+
plugin === null) {
|
|
33
|
+
return "expected a plugin object";
|
|
34
|
+
}
|
|
35
|
+
if (typeof Reflect.get(plugin, "process") !== "function") {
|
|
36
|
+
return "process must be a function";
|
|
37
|
+
}
|
|
38
|
+
for (const hook of EAGER_PLUGIN_HOOKS) {
|
|
39
|
+
const value = Reflect.get(plugin, hook);
|
|
40
|
+
if (value && typeof value !== "function") {
|
|
41
|
+
return `${hook} must be a function when set`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Reject, when it is piped, a plugin that could never work: one without a
|
|
48
|
+
* `process` function (it answered every matched request with a 500), or one
|
|
49
|
+
* whose `install`, `beforeRequest` or `onExchange` is a truthy non-function.
|
|
50
|
+
* The `install` and `beforeRequest` shapes already failed before this check;
|
|
51
|
+
* `onExchange` is new, so no working setup breaks.
|
|
52
|
+
*/
|
|
53
|
+
export function assertValidPlugin(plugin) {
|
|
54
|
+
const reason = describeInvalidPlugin(plugin);
|
|
55
|
+
if (reason === undefined)
|
|
56
|
+
return;
|
|
57
|
+
const name = typeof plugin === "object" && plugin !== null
|
|
58
|
+
? Reflect.get(plugin, "name")
|
|
59
|
+
: undefined;
|
|
60
|
+
const label = typeof name === "string" && name.length > 0 ? ` "${name}"` : "";
|
|
61
|
+
throw new SchmockError(`Invalid plugin${label}: ${reason}`, "PLUGIN_INVALID", {
|
|
62
|
+
plugin: typeof name === "string" ? name : undefined,
|
|
63
|
+
reason,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/** Whether any plugin observes exchanges (a function `onExchange`). */
|
|
67
|
+
export function hasExchangeObserver(plugins) {
|
|
68
|
+
return plugins.some((plugin) => typeof plugin.onExchange === "function");
|
|
69
|
+
}
|
|
70
|
+
/** A fresh, frozen copy of an exchange, built for one observer. */
|
|
71
|
+
function snapshotExchange(exchange) {
|
|
72
|
+
const { request } = exchange;
|
|
73
|
+
const requestCopy = Object.freeze({
|
|
74
|
+
method: request.method,
|
|
75
|
+
url: request.url,
|
|
76
|
+
headers: Object.freeze({ ...request.headers }),
|
|
77
|
+
...(request.body !== undefined
|
|
78
|
+
? { body: snapshotRequestBody(request.body) }
|
|
79
|
+
: {}),
|
|
80
|
+
});
|
|
81
|
+
const { startTime, endTime } = exchange;
|
|
82
|
+
if (exchange.outcome === "answered") {
|
|
83
|
+
const { response } = exchange;
|
|
84
|
+
return Object.freeze({
|
|
85
|
+
outcome: exchange.outcome,
|
|
86
|
+
request: requestCopy,
|
|
87
|
+
response: Object.freeze({
|
|
88
|
+
status: response.status,
|
|
89
|
+
headers: Object.freeze({ ...response.headers }),
|
|
90
|
+
...(response.body !== undefined
|
|
91
|
+
? { body: snapshotNormalizedBody(response.body) }
|
|
92
|
+
: {}),
|
|
93
|
+
}),
|
|
94
|
+
startTime,
|
|
95
|
+
endTime,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (exchange.outcome === "failed") {
|
|
99
|
+
return Object.freeze({
|
|
100
|
+
outcome: exchange.outcome,
|
|
101
|
+
request: requestCopy,
|
|
102
|
+
error: exchange.error,
|
|
103
|
+
startTime,
|
|
104
|
+
endTime,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return Object.freeze({
|
|
108
|
+
outcome: exchange.outcome,
|
|
109
|
+
request: requestCopy,
|
|
110
|
+
startTime,
|
|
111
|
+
endTime,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Report one settled exchange to each plugin's `onExchange`, in pipe order.
|
|
116
|
+
* Every observer gets its own frozen snapshot, built right before its call, so
|
|
117
|
+
* none sees or alters another's copy. A throwing or rejecting observer is
|
|
118
|
+
* logged and never stops the others. Reporting stops as soon as `isLive`
|
|
119
|
+
* returns false.
|
|
120
|
+
*/
|
|
121
|
+
export function runExchangeHooks(input) {
|
|
122
|
+
const { plugins, exchange, logger, isLive } = input;
|
|
123
|
+
for (const plugin of plugins) {
|
|
124
|
+
if (isLive !== undefined && !isLive())
|
|
125
|
+
return;
|
|
126
|
+
try {
|
|
127
|
+
const observe = plugin.onExchange;
|
|
128
|
+
if (typeof observe !== "function")
|
|
129
|
+
continue;
|
|
130
|
+
const result = Reflect.apply(observe, plugin, [
|
|
131
|
+
snapshotExchange(exchange),
|
|
132
|
+
]);
|
|
133
|
+
if (isThenable(result)) {
|
|
134
|
+
void Promise.resolve(result).catch((error) => {
|
|
135
|
+
logger.log("plugin", `Plugin ${plugin.name} onExchange rejected: ${errorMessage(error)}`);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
logger.log("plugin", `Plugin ${plugin.name} onExchange failed: ${errorMessage(error)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The instance a plugin hook receives. Reads are live; route registration is
|
|
146
|
+
* allowed only when the hook passes `registerRoute` (install does, uninstall
|
|
147
|
+
* does not); every other operation is rejected. `isActive` expires the
|
|
148
|
+
* facade when the hook returns, so a retained reference cannot act later.
|
|
149
|
+
*/
|
|
150
|
+
function createHookFacade(input) {
|
|
151
|
+
const { plugin, hook, isActive, reads, registerRoute } = input;
|
|
152
|
+
const codes = PLUGIN_HOOK_ERROR_CODES[hook];
|
|
153
|
+
const requireScope = () => {
|
|
154
|
+
if (isActive())
|
|
155
|
+
return;
|
|
156
|
+
throw new SchmockError(`Plugin "${plugin.name}" used its ${hook} instance outside ${hook}()`, codes.expired, { plugin: plugin.name });
|
|
157
|
+
};
|
|
158
|
+
const reject = (operation) => {
|
|
159
|
+
requireScope();
|
|
160
|
+
throw new SchmockError(`Plugin "${plugin.name}" cannot call ${operation} during ${hook}()`, codes.unsupported, { operation, plugin: plugin.name });
|
|
161
|
+
};
|
|
162
|
+
let facade;
|
|
163
|
+
const defineRoute = (route, generator, config = {}) => {
|
|
164
|
+
if (!registerRoute)
|
|
165
|
+
return reject("route registration");
|
|
166
|
+
requireScope();
|
|
167
|
+
registerRoute(route, generator, config);
|
|
168
|
+
return facade;
|
|
169
|
+
};
|
|
170
|
+
facade = Object.assign(defineRoute, {
|
|
171
|
+
pipe: () => reject("pipe()"),
|
|
172
|
+
handle: () => reject("handle()"),
|
|
173
|
+
history: (method, path) => {
|
|
174
|
+
requireScope();
|
|
175
|
+
return reads.history(method, path);
|
|
176
|
+
},
|
|
177
|
+
called: (method, path) => {
|
|
178
|
+
requireScope();
|
|
179
|
+
return reads.called(method, path);
|
|
180
|
+
},
|
|
181
|
+
callCount: (method, path) => {
|
|
182
|
+
requireScope();
|
|
183
|
+
return reads.callCount(method, path);
|
|
184
|
+
},
|
|
185
|
+
lastRequest: (method, path) => {
|
|
186
|
+
requireScope();
|
|
187
|
+
return reads.lastRequest(method, path);
|
|
188
|
+
},
|
|
189
|
+
reset: () => reject("reset()"),
|
|
190
|
+
resetHistory: () => reject("resetHistory()"),
|
|
191
|
+
resetState: () => reject("resetState()"),
|
|
192
|
+
on: () => reject("on()"),
|
|
193
|
+
off: () => reject("off()"),
|
|
194
|
+
getRoutes: () => {
|
|
195
|
+
requireScope();
|
|
196
|
+
return reads.getRoutes();
|
|
197
|
+
},
|
|
198
|
+
getState: () => {
|
|
199
|
+
requireScope();
|
|
200
|
+
return reads.getState();
|
|
201
|
+
},
|
|
202
|
+
listen: () => reject("listen()"),
|
|
203
|
+
close: () => reject("close()"),
|
|
204
|
+
intercept: () => reject("intercept()"),
|
|
205
|
+
});
|
|
206
|
+
return facade;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Run a plugin's `install()` against an expiring facade that may register
|
|
210
|
+
* routes. A Promise returned from `install()` is rejected: the routes it would
|
|
211
|
+
* register later could not be rolled back. The caller owns the rollback of
|
|
212
|
+
* whatever the hook registered before it threw.
|
|
213
|
+
*/
|
|
214
|
+
export function runInstallHook(input) {
|
|
215
|
+
const { plugin, logger } = input;
|
|
216
|
+
if (!plugin.install)
|
|
217
|
+
return;
|
|
218
|
+
let installActive = true;
|
|
219
|
+
const installFacade = createHookFacade({
|
|
220
|
+
plugin,
|
|
221
|
+
hook: "install",
|
|
222
|
+
isActive: () => installActive,
|
|
223
|
+
reads: input.reads,
|
|
224
|
+
registerRoute: input.registerRoute,
|
|
225
|
+
});
|
|
226
|
+
try {
|
|
227
|
+
const installResult = plugin.install(installFacade);
|
|
228
|
+
installActive = false;
|
|
229
|
+
if (isThenable(installResult)) {
|
|
230
|
+
void Promise.resolve(installResult).catch((error) => {
|
|
231
|
+
logger.log("plugin", `Rejected async install for ${plugin.name}: ${errorMessage(error)}`);
|
|
232
|
+
});
|
|
233
|
+
throw new SchmockError(`Plugin "${plugin.name}" returned a Promise from install()`, "PLUGIN_ASYNC_INSTALL_UNSUPPORTED", { plugin: plugin.name });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
installActive = false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Run `uninstall()` for each plugin, last piped first. A failing hook is
|
|
242
|
+
* logged and never stops the others.
|
|
243
|
+
*/
|
|
244
|
+
export function runUninstallHooks(input) {
|
|
245
|
+
const { plugins, logger } = input;
|
|
246
|
+
for (let index = plugins.length - 1; index >= 0; index -= 1) {
|
|
247
|
+
const plugin = plugins[index];
|
|
248
|
+
if (!plugin.uninstall)
|
|
249
|
+
continue;
|
|
250
|
+
// Cleanup gets a read-only, expiring instance: through the live one a
|
|
251
|
+
// plugin could pipe plugins or register routes into the mock that
|
|
252
|
+
// reset() just cleared.
|
|
253
|
+
let uninstallActive = true;
|
|
254
|
+
const uninstallFacade = createHookFacade({
|
|
255
|
+
plugin,
|
|
256
|
+
hook: "uninstall",
|
|
257
|
+
isActive: () => uninstallActive,
|
|
258
|
+
reads: input.reads,
|
|
259
|
+
});
|
|
260
|
+
try {
|
|
261
|
+
const uninstallResult = plugin.uninstall(uninstallFacade);
|
|
262
|
+
if (isThenable(uninstallResult)) {
|
|
263
|
+
void Promise.resolve(uninstallResult).catch((error) => {
|
|
264
|
+
logger.log("plugin", `Async uninstall for ${plugin.name} failed: ${errorMessage(error)}`);
|
|
265
|
+
});
|
|
266
|
+
logger.log("plugin", `Plugin ${plugin.name} returned an unsupported Promise from uninstall()`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
logger.log("plugin", `Plugin ${plugin.name} uninstall failed: ${errorMessage(error)}`);
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
uninstallActive = false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -19,4 +19,3 @@ export declare function recoverGeneratorError(plugins: readonly Schmock.Plugin[]
|
|
|
19
19
|
*/
|
|
20
20
|
export declare function runPluginPipeline(plugins: readonly Schmock.Plugin[], context: Schmock.PluginContext, initialResponse: unknown, logger: PipelineLogger, signal?: AbortSignal | undefined): Promise<PipelineResult>;
|
|
21
21
|
export {};
|
|
22
|
-
//# sourceMappingURL=plugin-pipeline.d.ts.map
|
package/dist/plugin-pipeline.js
CHANGED
|
@@ -7,6 +7,27 @@ function isPluginResult(value) {
|
|
|
7
7
|
typeof value.context === "object" &&
|
|
8
8
|
value.context !== null);
|
|
9
9
|
}
|
|
10
|
+
/** The failure a plugin hook raises by returning something that is not a PluginResult. */
|
|
11
|
+
function invalidPluginResultError(pluginName) {
|
|
12
|
+
return new PluginError(pluginName, new Error("didn't return valid result"));
|
|
13
|
+
}
|
|
14
|
+
function isAttributedTo(error, pluginName) {
|
|
15
|
+
const context = error.context;
|
|
16
|
+
return (typeof context === "object" &&
|
|
17
|
+
context !== null &&
|
|
18
|
+
"pluginName" in context &&
|
|
19
|
+
context.pluginName === pluginName);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Attribute an unrecovered failure to the plugin that raised it, once: an
|
|
23
|
+
* error that already is that plugin's PluginError (an invalid result) is not
|
|
24
|
+
* wrapped a second time.
|
|
25
|
+
*/
|
|
26
|
+
function toPluginError(pluginName, error) {
|
|
27
|
+
return error instanceof PluginError && isAttributedTo(error, pluginName)
|
|
28
|
+
? error
|
|
29
|
+
: new PluginError(pluginName, error);
|
|
30
|
+
}
|
|
10
31
|
function preserveRequestSignal(context, signal) {
|
|
11
32
|
return context.signal === signal ? context : { ...context, signal };
|
|
12
33
|
}
|
|
@@ -55,7 +76,7 @@ export async function runPluginBeforeRequest(plugins, context, logger, signal =
|
|
|
55
76
|
if (result === undefined)
|
|
56
77
|
continue;
|
|
57
78
|
if (!isPluginResult(result)) {
|
|
58
|
-
throw
|
|
79
|
+
throw invalidPluginResultError(plugin.name);
|
|
59
80
|
}
|
|
60
81
|
currentContext = preserveRequestSignal(result.context, signal);
|
|
61
82
|
if (result.response !== undefined) {
|
|
@@ -79,7 +100,7 @@ export async function runPluginBeforeRequest(plugins, context, logger, signal =
|
|
|
79
100
|
requestShortCircuited: true,
|
|
80
101
|
};
|
|
81
102
|
}
|
|
82
|
-
throw
|
|
103
|
+
throw toPluginError(plugin.name, recovery.error);
|
|
83
104
|
}
|
|
84
105
|
}
|
|
85
106
|
return { context: currentContext };
|
|
@@ -115,7 +136,7 @@ export async function runPluginPipeline(plugins, context, initialResponse, logge
|
|
|
115
136
|
const result = await awaitWithAbort(plugin.process(currentContext, response), signal);
|
|
116
137
|
throwIfAborted(signal);
|
|
117
138
|
if (!isPluginResult(result)) {
|
|
118
|
-
throw
|
|
139
|
+
throw invalidPluginResultError(plugin.name);
|
|
119
140
|
}
|
|
120
141
|
currentContext = preserveRequestSignal(result.context, signal);
|
|
121
142
|
// First plugin to set response becomes the generator
|
|
@@ -140,7 +161,7 @@ export async function runPluginPipeline(plugins, context, initialResponse, logge
|
|
|
140
161
|
recoveredFromError: true,
|
|
141
162
|
};
|
|
142
163
|
}
|
|
143
|
-
throw
|
|
164
|
+
throw toPluginError(plugin.name, recovery.error);
|
|
144
165
|
}
|
|
145
166
|
}
|
|
146
167
|
return { context: currentContext, response };
|
|
@@ -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
|