@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.
Files changed (68) hide show
  1. package/README.md +129 -0
  2. package/dist/abort.d.ts +11 -1
  3. package/dist/abort.js +13 -2
  4. package/dist/adapter.d.ts +21 -0
  5. package/dist/adapter.js +19 -0
  6. package/dist/admission.d.ts +21 -0
  7. package/dist/admission.js +39 -0
  8. package/dist/binary.d.ts +0 -1
  9. package/dist/builder.d.ts +16 -32
  10. package/dist/builder.js +425 -904
  11. package/dist/constants.d.ts +33 -2
  12. package/dist/constants.js +73 -1
  13. package/dist/debug-logger.d.ts +10 -0
  14. package/dist/debug-logger.js +31 -0
  15. package/dist/delay.d.ts +12 -0
  16. package/dist/delay.js +37 -0
  17. package/dist/errors.d.ts +13 -2
  18. package/dist/errors.js +21 -2
  19. package/dist/events.d.ts +17 -0
  20. package/dist/events.js +58 -0
  21. package/dist/generations.d.ts +48 -0
  22. package/dist/generations.js +82 -0
  23. package/dist/headers.d.ts +27 -0
  24. package/dist/headers.js +57 -0
  25. package/dist/helpers.d.ts +9 -10
  26. package/dist/helpers.js +4 -1
  27. package/dist/history.d.ts +56 -0
  28. package/dist/history.js +151 -0
  29. package/dist/http-helpers.d.ts +110 -5
  30. package/dist/http-helpers.js +328 -46
  31. package/dist/index.d.ts +295 -31
  32. package/dist/index.js +17 -9
  33. package/dist/interceptor.d.ts +40 -10
  34. package/dist/interceptor.js +412 -178
  35. package/dist/node-server.d.ts +27 -0
  36. package/dist/node-server.js +166 -0
  37. package/dist/parser.d.ts +0 -1
  38. package/dist/parser.js +145 -22
  39. package/dist/plugin-hooks.d.ts +57 -0
  40. package/dist/plugin-hooks.js +276 -0
  41. package/dist/plugin-pipeline.d.ts +0 -1
  42. package/dist/plugin-pipeline.js +25 -4
  43. package/dist/response-normalizer.d.ts +36 -1
  44. package/dist/response-normalizer.js +102 -0
  45. package/dist/response-parser.d.ts +19 -1
  46. package/dist/response-parser.js +77 -19
  47. package/dist/route-matcher.d.ts +0 -1
  48. package/dist/route-table.d.ts +64 -0
  49. package/dist/route-table.js +220 -0
  50. package/dist/snapshot.d.ts +14 -0
  51. package/dist/snapshot.js +98 -0
  52. package/dist/types.d.ts +34 -1
  53. package/package.json +8 -3
  54. package/dist/abort.d.ts.map +0 -1
  55. package/dist/binary.d.ts.map +0 -1
  56. package/dist/builder.d.ts.map +0 -1
  57. package/dist/constants.d.ts.map +0 -1
  58. package/dist/errors.d.ts.map +0 -1
  59. package/dist/helpers.d.ts.map +0 -1
  60. package/dist/http-helpers.d.ts.map +0 -1
  61. package/dist/index.d.ts.map +0 -1
  62. package/dist/interceptor.d.ts.map +0 -1
  63. package/dist/parser.d.ts.map +0 -1
  64. package/dist/plugin-pipeline.d.ts.map +0 -1
  65. package/dist/response-normalizer.d.ts.map +0 -1
  66. package/dist/response-parser.d.ts.map +0 -1
  67. package/dist/route-matcher.d.ts.map +0 -1
  68. 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
@@ -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 new Error(`Plugin ${plugin.name} didn't return valid result`);
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 new PluginError(plugin.name, recovery.error);
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 new Error(`Plugin ${plugin.name} didn't return valid result`);
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 new PluginError(plugin.name, recovery.error);
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
@@ -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 Object.keys(headers).some((header) => header.toLowerCase() === "content-type");
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 must use this same
39
- * rule (see `@schmock/validation`) or they will judge an undelivered payload.
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
- * Parse and normalize response result into Response object
60
- * Handles tuple format [status, body, headers], direct values, and response objects
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
- export function parseResponse(result, routeConfig) {
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
- status = result.status;
70
- body = result.body;
71
- headers = toOwnHeaderRecord(result.headers);
72
- tupleFormat = true;
68
+ return {
69
+ kind: "object",
70
+ status: result.status,
71
+ body: result.body,
72
+ rawHeaders: result.headers,
73
+ };
73
74
  }
74
- else if (isStatusTuple(result)) {
75
- // Handle tuple response format [status, body, headers?]
76
- [status, body] = result;
77
- headers = toOwnHeaderRecord(result[2]);
78
- tupleFormat = true;
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) {
@@ -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