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"