diff --git a/.circleci/config.yml b/.circleci/config.yml
index 92575c22..91cc887b 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -8,7 +8,7 @@ references:
node18: &node18
docker:
- - image: cimg/node:18.0
+ - image: cimg/node:18.11
nodelts: &nodelts
docker:
- image: cimg/node:lts
diff --git a/docs/docs/@fetch-mock/core/configuration.md b/docs/docs/@fetch-mock/core/configuration.md
index c1a6b563..fee495a4 100644
--- a/docs/docs/@fetch-mock/core/configuration.md
+++ b/docs/docs/@fetch-mock/core/configuration.md
@@ -15,17 +15,11 @@ fetchMock.config.sendAsJson = false;
Options marked with a `†` can also be overridden for individual calls to `.mock(matcher, response, options)` by setting as properties on the `options` parameter
-### sendAsJson†
-
-`{Boolean}` default: `true`
-
-Always convert objects passed to `.mock()` to JSON strings before building reponses. Can be useful to set to `false` globally if e.g. dealing with a lot of `ArrayBuffer`s. When `true` the `Content-Type: application/json` header will also be set on each response.
-
### includeContentLength†
`{Boolean}` default: `true`
-Sets a `Content-Length` header on each response.
+Sets a `Content-Length` header on each response, with the exception of responses whose body is a `FormData` or `ReadableStream` instance as these are hard/impossible to calculate up front.
### matchPartialBody
diff --git a/docs/docs/@fetch-mock/core/route/response.md b/docs/docs/@fetch-mock/core/route/response.md
index 81bd4c15..44a05563 100644
--- a/docs/docs/@fetch-mock/core/route/response.md
+++ b/docs/docs/@fetch-mock/core/route/response.md
@@ -32,9 +32,13 @@ If the object _only_ contains properties from among those listed below it is use
### body
-`{String|Object}`
+`{String|Object|BodyInit}`
-Set the `Response` body, e.g. `"Server responded ok"`, `{ token: 'abcdef' }`. See the `Object` section of the docs below for behaviour when passed an `Object`.
+Set the `Response` body. This could be
+
+- a string e.g. `"Server responded ok"`, `{ token: 'abcdef' }`.
+- an object literal (see the `Object` section of the docs below).
+- Anything else that satisfies the specification for the [body parameter of new Response()](https://developer.mozilla.org/en-US/docs/Web/API/Response/Response#body). This currently allows instances of Blob, ArrayBuffer, TypedArray, DataView, FormData, ReadableStream, URLSearchParams, and String.
### status
@@ -62,9 +66,15 @@ Forces `fetch` to return a `Promise` rejected with the value of `throws` e.g. `n
## Object
-`{Object|ArrayBuffer|...`
+`{Object}`
+
+Any object literal that does not match the schema for a response config will be converted to a `JSON` string and set as the response `body`.
+
+The `Content-Type: application/json` header will also be set on each response. To send JSON responses that do not set this header (e.g. to mock a poorly configured server) manually convert the object to a string first e.g.
-If the `sendAsJson` option is set to `true`, any object that does not match the schema for a response config will be converted to a `JSON` string and set as the response `body`. Otherwise, the object will be set as the response `body` (useful for `ArrayBuffer`s etc.)
+```js
+fetchMock.route('http://a.com', JSON.stringify({ prop: 'value' }));
+```
## Promise
diff --git a/package-lock.json b/package-lock.json
index bb8d6f29..6d6c41c5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12077,6 +12077,15 @@
"node": "^12.20 || >= 14.13"
}
},
+ "node_modules/fetch-blob/node_modules/web-streams-polyfill": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
+ "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/fetch-mock": {
"resolved": "packages/fetch-mock",
"link": true
@@ -25499,15 +25508,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
- "node_modules/web-streams-polyfill": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
- "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
- "dev": true,
- "engines": {
- "node": ">= 8"
- }
- },
"node_modules/webdriver": {
"version": "8.40.2",
"resolved": "https://registry.npmjs.org/webdriver/-/webdriver-8.40.2.tgz",
@@ -26450,7 +26450,7 @@
"packages/vitest": {
"name": "@fetch-mock/vitest",
"version": "0.1.2",
- "license": "ISC",
+ "license": "MIT",
"dependencies": {
"@fetch-mock/core": "^0.6.3"
},
diff --git a/packages/core/package.json b/packages/core/package.json
index 8c3fdda2..e0a1eae9 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -18,7 +18,7 @@
"types": "./dist/esm/index.d.ts",
"type": "module",
"engines": {
- "node": ">=18.0.0"
+ "node": ">=18.11.0"
},
"dependencies": {
"@types/glob-to-regexp": "^0.4.4",
diff --git a/packages/core/src/FetchMock.ts b/packages/core/src/FetchMock.ts
index 46db9d0e..b5f8f47c 100644
--- a/packages/core/src/FetchMock.ts
+++ b/packages/core/src/FetchMock.ts
@@ -5,7 +5,6 @@ import CallHistory from './CallHistory.js';
import * as requestUtils from './RequestUtils.js';
export type FetchMockGlobalConfig = {
- sendAsJson?: boolean;
includeContentLength?: boolean;
matchPartialBody?: boolean;
allowRelativeUrls?: boolean;
@@ -20,7 +19,6 @@ export type FetchMockConfig = FetchMockGlobalConfig & FetchImplementations;
export const defaultFetchMockConfig: FetchMockConfig = {
includeContentLength: true,
- sendAsJson: true,
matchPartialBody: false,
Request: globalThis.Request,
Response: globalThis.Response,
diff --git a/packages/core/src/Route.ts b/packages/core/src/Route.ts
index 2d341f08..85b65e99 100644
--- a/packages/core/src/Route.ts
+++ b/packages/core/src/Route.ts
@@ -42,7 +42,7 @@ export type RouteConfig = UserRouteConfig &
FetchImplementations &
InternalRouteConfig;
export type RouteResponseConfig = {
- body?: string | object;
+ body?: BodyInit | object;
status?: number;
headers?: {
[key: string]: string;
@@ -72,6 +72,22 @@ export type RouteResponse =
| RouteResponseFunction;
export type RouteName = string;
+function isBodyInit(body: BodyInit | object): body is BodyInit {
+ return (
+ body instanceof Blob ||
+ body instanceof ArrayBuffer ||
+ // checks for TypedArray
+ ArrayBuffer.isView(body) ||
+ body instanceof DataView ||
+ body instanceof FormData ||
+ body instanceof ReadableStream ||
+ body instanceof URLSearchParams ||
+ body instanceof String ||
+ typeof body === 'string' ||
+ body === null
+ );
+}
+
function sanitizeStatus(status?: number): number {
if (!status) {
return 200;
@@ -194,31 +210,48 @@ class Route {
constructResponseBody(
responseInput: RouteResponseConfig,
responseOptions: ResponseInitUsingHeaders,
- ): string | null {
- // start to construct the body
+ ): BodyInit {
let body = responseInput.body;
- // convert to json if we need to
- if (typeof body === 'object') {
- if (this.config.sendAsJson && responseInput.body != null) {
+ const bodyIsBodyInit = isBodyInit(body);
+
+ if (!bodyIsBodyInit) {
+ if (typeof body === 'undefined') {
+ body = null;
+ } else if (typeof body === 'object') {
+ // convert to json if we need to
body = JSON.stringify(body);
if (!responseOptions.headers.has('Content-Type')) {
responseOptions.headers.set('Content-Type', 'application/json');
}
+ } else {
+ throw new TypeError('Invalid body provided to construct response');
}
}
- if (typeof body === 'string') {
- // add a Content-Length header if we need to
- if (
- this.config.includeContentLength &&
- !responseOptions.headers.has('Content-Length')
+ // add a Content-Length header if we need to
+ if (
+ this.config.includeContentLength &&
+ !responseOptions.headers.has('Content-Length') &&
+ !(body instanceof ReadableStream) &&
+ !(body instanceof FormData)
+ ) {
+ let length = 0;
+ if (body instanceof Blob) {
+ length = body.size;
+ } else if (
+ body instanceof ArrayBuffer ||
+ ArrayBuffer.isView(body) ||
+ body instanceof DataView
) {
- responseOptions.headers.set('Content-Length', body.length.toString());
+ length = body.byteLength;
+ } else if (body instanceof URLSearchParams) {
+ length = body.toString().length;
+ } else if (typeof body === 'string' || body instanceof String) {
+ length = body.length;
}
- return body;
+ responseOptions.headers.set('Content-Length', length.toString());
}
- // @ts-expect-error TODO need to implement handling of non-string bodies properlyy
- return body || null;
+ return body as BodyInit;
}
static defineMatcher(matcher: MatcherDefinition) {
diff --git a/packages/core/src/Router.ts b/packages/core/src/Router.ts
index 54489158..be3af6c9 100644
--- a/packages/core/src/Router.ts
+++ b/packages/core/src/Router.ts
@@ -273,8 +273,8 @@ export default class Router {
if (typeof response[name] === 'function') {
//@ts-expect-error TODO probably make use of generics here
return new Proxy(response[name], {
- apply: (matcherFunction, thisArg, args) => {
- const result = matcherFunction.apply(response, args);
+ apply: (func, thisArg, args) => {
+ const result = func.apply(response, args);
if (result.then) {
pendingPromises.push(
//@ts-expect-error TODO probably make use of generics here
diff --git a/packages/core/src/__tests__/FetchMock/response-construction.test.js b/packages/core/src/__tests__/FetchMock/response-construction.test.js
index 95d5deb5..15fae2d8 100644
--- a/packages/core/src/__tests__/FetchMock/response-construction.test.js
+++ b/packages/core/src/__tests__/FetchMock/response-construction.test.js
@@ -1,8 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest';
-
import fetchMock from '../../FetchMock';
-describe('response generation', () => {
+describe('response construction', () => {
let fm;
beforeEach(() => {
fm = fetchMock.createInstance();
@@ -48,128 +47,159 @@ describe('response generation', () => {
});
});
- describe('json', () => {
- it('respond with a json', async () => {
- fm.route('*', { an: 'object' });
- const res = await fm.fetchHandler('http://a.com/');
- expect(res.status).toEqual(200);
- expect(res.statusText).toEqual('OK');
- expect(res.headers.get('content-type')).toEqual('application/json');
- expect(await res.json()).toEqual({ an: 'object' });
+ it('respond with a complex response, including headers', async () => {
+ fm.route('*', {
+ status: 202,
+ body: { an: 'object' },
+ headers: {
+ header: 'val',
+ },
});
-
- it('convert body properties to json', async () => {
- fm.route('*', {
- body: { an: 'object' },
- });
- const res = await fm.fetchHandler('http://a.com/');
- expect(res.headers.get('content-type')).toEqual('application/json');
- expect(await res.json()).toEqual({ an: 'object' });
+ const res = await fm.fetchHandler('http://a.com/');
+ expect(res.status).toEqual(202);
+ expect(res.headers.get('header')).toEqual('val');
+ expect(await res.json()).toEqual({ an: 'object' });
+ });
+ describe('encoded and streamed data', () => {
+ function ab2str(buf) {
+ return String.fromCharCode.apply(null, new Uint16Array(buf));
+ }
+
+ function str2ab(str) {
+ var buf = new ArrayBuffer(str.length * 2); // 2 bytes for each char
+ var bufView = new Uint16Array(buf);
+ for (var i = 0, strLen = str.length; i < strLen; i++) {
+ bufView[i] = str.charCodeAt(i);
+ }
+ return buf;
+ }
+
+ it('respond with Blob', async () => {
+ const blobParts = ['test value'];
+ const body = new Blob(blobParts);
+ fm.route('*', body);
+ const res = await fm.fetchHandler('http://a.com');
+ expect(res.status).to.equal(200);
+ const receivedData = await res.blob();
+ expect(receivedData).to.eql(body);
+ expect(res.headers.get('content-length')).toEqual('10');
});
-
- it('not overide existing content-type-header', async () => {
- fm.route('*', {
- body: { an: 'object' },
- headers: {
- 'content-type': 'text/html',
- },
- });
- const res = await fm.fetchHandler('http://a.com/');
- expect(res.headers.get('content-type')).toEqual('text/html');
- expect(await res.json()).toEqual({ an: 'object' });
+ it('respond with ArrayBuffer', async () => {
+ const body = str2ab('test value');
+ fm.route('*', body);
+ const res = await fm.fetchHandler('http://a.com');
+ expect(res.status).to.equal(200);
+ const receivedData = await res.arrayBuffer();
+ expect(ab2str(receivedData)).to.eql('test value');
+ expect(res.headers.get('content-length')).toEqual('20');
});
-
- it('not convert if `body` property exists', async () => {
- fm.route('*', { body: 'exists' });
- const res = await fm.fetchHandler('http://a.com/');
- expect(res.headers.get('content-type')).not.toEqual('application/json');
+ it('respond with TypedArray', async () => {
+ const buffer = str2ab('test value');
+ const body = new Uint8Array(buffer);
+ fm.route('*', body);
+ const res = await fm.fetchHandler('http://a.com');
+ expect(res.status).to.equal(200);
+ const receivedData = await res.arrayBuffer();
+ expect(new Uint8Array(receivedData)).to.eql(body);
+ expect(res.headers.get('content-length')).toEqual('20');
});
-
- it('not convert if `headers` property exists', async () => {
- fm.route('*', { headers: {} });
- const res = await fm.fetchHandler('http://a.com/');
- expect(res.headers.get('content-type')).toBeNull();
+ it('respond with DataView', async () => {
+ const buffer = str2ab('test value');
+ const body = new DataView(buffer, 0);
+ fm.route('*', body);
+ const res = await fm.fetchHandler('http://a.com');
+ expect(res.status).to.equal(200);
+ const receivedData = await res.arrayBuffer();
+ expect(new DataView(receivedData, 0)).to.eql(body);
+ expect(res.headers.get('content-length')).toEqual('20');
});
- it('not convert if `status` property exists', async () => {
- fm.route('*', { status: 300 });
- const res = await fm.fetchHandler('http://a.com/');
- expect(res.headers.get('content-type')).toBeNull();
+ it('respond with ReadableStream', async () => {
+ const body = new Blob(['test value']).stream();
+ fm.route('*', body);
+ const res = await fm.fetchHandler('http://a.com');
+ expect(res.status).to.equal(200);
+ const receivedData = await res.text();
+ expect(receivedData).to.eql('test value');
+ expect(res.headers.get('content-length')).toBe(null);
});
-
- it('convert if non-whitelisted property exists', async () => {
- fm.route('*', { status: 300, weird: true });
- const res = await fm.fetchHandler('http://a.com/');
- expect(res.headers.get('content-type')).toEqual('application/json');
+ });
+ describe('structured data', () => {
+ it('respond with FormData', async () => {
+ const body = new FormData();
+ body.append('field', 'value');
+ fm.route('*', body);
+ const res = await fm.fetchHandler('http://a.com');
+ expect(res.status).to.equal(200);
+ const receivedData = await res.formData();
+ expect(receivedData).to.eql(body);
+ expect(res.headers.get('content-length')).toBe(null);
});
-
- describe('sendAsJson option', () => {
- it('convert object responses to json by default', async () => {
+ it('respond with URLSearchParams', async () => {
+ const body = new URLSearchParams();
+ body.append('field', 'value');
+ fm.route('*', body);
+ const res = await fm.fetchHandler('http://a.com');
+ expect(res.status).to.equal(200);
+ const receivedData = await res.formData();
+ expect(receivedData.get('field')).to.equal('value');
+ expect(res.headers.get('content-length')).toEqual('11');
+ });
+ describe('json', () => {
+ it('respond with a json', async () => {
fm.route('*', { an: 'object' });
- const res = await fm.fetchHandler('http://it.at.there');
+ const res = await fm.fetchHandler('http://a.com/');
+ expect(res.status).toEqual(200);
+ expect(res.statusText).toEqual('OK');
expect(res.headers.get('content-type')).toEqual('application/json');
+ expect(await res.json()).toEqual({ an: 'object' });
});
- it("don't convert when configured false", async () => {
- fm.config.sendAsJson = false;
- fm.route('*', { an: 'object' });
- const res = await fm.fetchHandler('http://it.at.there');
- // can't check for existence as the spec says, in the browser, that
- // a default value should be set
- expect(res.headers.get('content-type')).not.toEqual('application/json');
+ it('convert body properties to json', async () => {
+ fm.route('*', {
+ body: { an: 'object' },
+ });
+ const res = await fm.fetchHandler('http://a.com/');
+ expect(res.headers.get('content-type')).toEqual('application/json');
+ expect(await res.json()).toEqual({ an: 'object' });
});
- it('local setting can override to true', async () => {
- fm.config.sendAsJson = false;
- fm.route('*', { an: 'object' }, { sendAsJson: true });
- const res = await fm.fetchHandler('http://it.at.there');
- expect(res.headers.get('content-type')).toEqual('application/json');
+ it('not overide existing content-type-header', async () => {
+ fm.route('*', {
+ body: { an: 'object' },
+ headers: {
+ 'content-type': 'text/html',
+ },
+ });
+ const res = await fm.fetchHandler('http://a.com/');
+ expect(res.headers.get('content-type')).toEqual('text/html');
+ expect(await res.json()).toEqual({ an: 'object' });
});
- it('local setting can override to false', async () => {
- fm.config.sendAsJson = true;
- fm.route('*', { an: 'object' }, { sendAsJson: false });
- const res = await fm.fetchHandler('http://it.at.there');
- // can't check for existence as the spec says, in the browser, that
- // a default value should be set
+ it('not convert if `body` property exists', async () => {
+ fm.route('*', { body: 'exists' });
+ const res = await fm.fetchHandler('http://a.com/');
expect(res.headers.get('content-type')).not.toEqual('application/json');
});
- });
- });
- it('respond with a complex response, including headers', async () => {
- fm.route('*', {
- status: 202,
- body: { an: 'object' },
- headers: {
- header: 'val',
- },
- });
- const res = await fm.fetchHandler('http://a.com/');
- expect(res.status).toEqual(202);
- expect(res.headers.get('header')).toEqual('val');
- expect(await res.json()).toEqual({ an: 'object' });
- });
+ it('not convert if `headers` property exists', async () => {
+ fm.route('*', { headers: {} });
+ const res = await fm.fetchHandler('http://a.com/');
+ expect(res.headers.get('content-type')).toBeNull();
+ });
- if (typeof Buffer !== 'undefined') {
- it('can respond with a buffer', () => {
- fm.route(/a/, new Buffer('buffer'), { sendAsJson: false });
- return fm
- .fetchHandler('http://a.com')
- .then((res) => res.text())
- .then((txt) => {
- expect(txt).to.equal('buffer');
- });
- });
- }
+ it('not convert if `status` property exists', async () => {
+ fm.route('*', { status: 300 });
+ const res = await fm.fetchHandler('http://a.com/');
+ expect(res.headers.get('content-type')).toBeNull();
+ });
- it('respond with blob', async () => {
- const blob = new Blob();
- fm.route('*', blob, { sendAsJson: false });
- const res = await fm.fetchHandler('http://a.com');
- expect(res.status).to.equal(200);
- const blobData = await res.blob();
- expect(blobData).to.eql(blob);
+ it('convert if non-whitelisted property exists', async () => {
+ fm.route('*', { status: 300, weird: true });
+ const res = await fm.fetchHandler('http://a.com/');
+ expect(res.headers.get('content-type')).toEqual('application/json');
+ });
+ });
});
it('should set the url property on responses', async () => {
diff --git a/packages/vitest/package.json b/packages/vitest/package.json
index 1a8af1f5..b15eaeef 100644
--- a/packages/vitest/package.json
+++ b/packages/vitest/package.json
@@ -13,7 +13,7 @@
"types": "./types/index.d.ts",
"type": "module",
"engines": {
- "node": ">=18.0.0"
+ "node": ">=18.11.0"
},
"dependencies": {
"@fetch-mock/core": "^0.6.3"