From 0242ea2d6c30e36418f21a37a962fe1cf84c9271 Mon Sep 17 00:00:00 2001 From: Rhys Evans Date: Thu, 29 Aug 2024 19:30:15 +0100 Subject: [PATCH 01/12] fix: handle all types of BodyInit correctly --- packages/core/src/Route.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/core/src/Route.ts b/packages/core/src/Route.ts index 2d341f08..007b58a1 100644 --- a/packages/core/src/Route.ts +++ b/packages/core/src/Route.ts @@ -194,7 +194,7 @@ class Route { constructResponseBody( responseInput: RouteResponseConfig, responseOptions: ResponseInitUsingHeaders, - ): string | null { + ): BodyInit | null { // start to construct the body let body = responseInput.body; // convert to json if we need to @@ -217,8 +217,25 @@ class Route { } return body; } - // @ts-expect-error TODO need to implement handling of non-string bodies properlyy - return body || null; + + if ( + 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' + ) { + return body as BodyInit; + } + if (!body) { + return null; + } + throw new TypeError('Invalid body provided to construct response'); } static defineMatcher(matcher: MatcherDefinition) { From cc5433a12aadc91312068e3883cdb979a4912c30 Mon Sep 17 00:00:00 2001 From: Rhys Evans Date: Thu, 29 Aug 2024 19:53:00 +0100 Subject: [PATCH 02/12] refactor: check type f response body up front --- packages/core/src/Route.ts | 51 ++++++++++--------- .../FetchMock/response-construction.test.js | 2 +- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/packages/core/src/Route.ts b/packages/core/src/Route.ts index 007b58a1..1d10ff96 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 | null; status?: number; headers?: { [key: string]: string; @@ -72,6 +72,22 @@ export type RouteResponse = | RouteResponseFunction; export type RouteName = string; +function isBodyInit(body: BodyInit | null | 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; @@ -195,15 +211,20 @@ class Route { responseInput: RouteResponseConfig, responseOptions: ResponseInitUsingHeaders, ): BodyInit | null { - // start to construct the body 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' && this.config.sendAsJson) { + // 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'); } } @@ -215,27 +236,9 @@ class Route { ) { responseOptions.headers.set('Content-Length', body.length.toString()); } - return body; } - if ( - 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' - ) { - return body as BodyInit; - } - if (!body) { - return null; - } - throw new TypeError('Invalid body provided to construct response'); + return body as BodyInit; } static defineMatcher(matcher: MatcherDefinition) { diff --git a/packages/core/src/__tests__/FetchMock/response-construction.test.js b/packages/core/src/__tests__/FetchMock/response-construction.test.js index 95d5deb5..6b5edd22 100644 --- a/packages/core/src/__tests__/FetchMock/response-construction.test.js +++ b/packages/core/src/__tests__/FetchMock/response-construction.test.js @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import fetchMock from '../../FetchMock'; -describe('response generation', () => { +describe('response construction', () => { let fm; beforeEach(() => { fm = fetchMock.createInstance(); From 4b11fc4a5c92c81de8f89b1993a57c2645805ddb Mon Sep 17 00:00:00 2001 From: Rhys Evans Date: Thu, 29 Aug 2024 20:01:21 +0100 Subject: [PATCH 03/12] feat!: remove sendAsJson option --- packages/core/src/FetchMock.ts | 2 - packages/core/src/Route.ts | 2 +- .../FetchMock/response-construction.test.js | 37 +------------------ 3 files changed, 3 insertions(+), 38 deletions(-) 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 1d10ff96..471d67b2 100644 --- a/packages/core/src/Route.ts +++ b/packages/core/src/Route.ts @@ -217,7 +217,7 @@ class Route { if (!bodyIsBodyInit) { if (typeof body === 'undefined') { body = null - } else if (typeof body === 'object' && this.config.sendAsJson) { + } else if (typeof body === 'object') { // convert to json if we need to body = JSON.stringify(body); if (!responseOptions.headers.has('Content-Type')) { diff --git a/packages/core/src/__tests__/FetchMock/response-construction.test.js b/packages/core/src/__tests__/FetchMock/response-construction.test.js index 6b5edd22..6ca20770 100644 --- a/packages/core/src/__tests__/FetchMock/response-construction.test.js +++ b/packages/core/src/__tests__/FetchMock/response-construction.test.js @@ -102,39 +102,6 @@ describe('response construction', () => { const res = await fm.fetchHandler('http://a.com/'); expect(res.headers.get('content-type')).toEqual('application/json'); }); - - describe('sendAsJson option', () => { - it('convert object responses to json by default', async () => { - fm.route('*', { an: 'object' }); - const res = await fm.fetchHandler('http://it.at.there'); - expect(res.headers.get('content-type')).toEqual('application/json'); - }); - - 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('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('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 - expect(res.headers.get('content-type')).not.toEqual('application/json'); - }); - }); }); it('respond with a complex response, including headers', async () => { @@ -153,7 +120,7 @@ describe('response construction', () => { if (typeof Buffer !== 'undefined') { it('can respond with a buffer', () => { - fm.route(/a/, new Buffer('buffer'), { sendAsJson: false }); + fm.route(/a/, new Buffer('buffer')); return fm .fetchHandler('http://a.com') .then((res) => res.text()) @@ -165,7 +132,7 @@ describe('response construction', () => { it('respond with blob', async () => { const blob = new Blob(); - fm.route('*', blob, { sendAsJson: false }); + fm.route('*', blob); const res = await fm.fetchHandler('http://a.com'); expect(res.status).to.equal(200); const blobData = await res.blob(); From 6fcae52edbb55caa4a557d89d98cb1f8182b4ea2 Mon Sep 17 00:00:00 2001 From: Rhys Evans Date: Thu, 29 Aug 2024 20:12:33 +0100 Subject: [PATCH 04/12] docs: document the changes to what types of body are acceptable --- docs/docs/@fetch-mock/core/configuration.md | 6 ------ docs/docs/@fetch-mock/core/route/response.md | 18 ++++++++++++++---- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/docs/@fetch-mock/core/configuration.md b/docs/docs/@fetch-mock/core/configuration.md index c1a6b563..96773735 100644 --- a/docs/docs/@fetch-mock/core/configuration.md +++ b/docs/docs/@fetch-mock/core/configuration.md @@ -15,12 +15,6 @@ 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` 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 From ecb2bdfad5c95bd0b129f334e0c0acd068b5aba1 Mon Sep 17 00:00:00 2001 From: Rhys Evans Date: Thu, 29 Aug 2024 21:20:09 +0100 Subject: [PATCH 05/12] chore: lint --- packages/core/src/Route.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/Route.ts b/packages/core/src/Route.ts index 471d67b2..2aa2fb79 100644 --- a/packages/core/src/Route.ts +++ b/packages/core/src/Route.ts @@ -83,9 +83,9 @@ function isBodyInit(body: BodyInit | null | object): body is BodyInit { body instanceof ReadableStream || body instanceof URLSearchParams || body instanceof String || - typeof body === 'string' || + typeof body === 'string' || body === null - ) + ); } function sanitizeStatus(status?: number): number { @@ -216,7 +216,7 @@ class Route { if (!bodyIsBodyInit) { if (typeof body === 'undefined') { - body = null + body = null; } else if (typeof body === 'object') { // convert to json if we need to body = JSON.stringify(body); From 3189c1cc38455bafd65b71161c9d4519a7b3a704 Mon Sep 17 00:00:00 2001 From: Rhys Evans Date: Thu, 29 Aug 2024 21:54:10 +0100 Subject: [PATCH 06/12] test: fixed most tests for encoded data --- package-lock.json | 20 ++--- .../FetchMock/response-construction.test.js | 86 +++++++++++++++---- 2 files changed, 80 insertions(+), 26 deletions(-) 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/src/__tests__/FetchMock/response-construction.test.js b/packages/core/src/__tests__/FetchMock/response-construction.test.js index 6ca20770..4d2f8988 100644 --- a/packages/core/src/__tests__/FetchMock/response-construction.test.js +++ b/packages/core/src/__tests__/FetchMock/response-construction.test.js @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it } from 'vitest'; - import fetchMock from '../../FetchMock'; describe('response construction', () => { @@ -117,26 +116,81 @@ describe('response construction', () => { 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; + } - if (typeof Buffer !== 'undefined') { - it('can respond with a buffer', () => { - fm.route(/a/, new Buffer('buffer')); - return fm - .fetchHandler('http://a.com') - .then((res) => res.text()) - .then((txt) => { - expect(txt).to.equal('buffer'); - }); + it('respond with Blob', async () => { + const blobParts = ['hey!']; + const body = new Blob(blobParts, { type: 'text/html' }); + 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); + }); + 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'); + }); + 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); + }); + 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); }); - } - it('respond with blob', async () => { - const blob = new Blob(); - fm.route('*', blob); + it('respond with ReadableStream', async () => { + const body = ReadableStream.from(['a', 'b']); + fm.route('*', body); + const res = await fm.fetchHandler('http://a.com'); + expect(res.status).to.equal(200); + const receivedData = await res.text(); + console.log(receivedData); + expect(receivedData).to.eql(body); + }); + }); + it('respond with FormData', async () => { + const body = new FormData(); + 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); + }); + it('respond with URLSearchParams', async () => { + const body = new URLSearchParams(); + fm.route('*', body); const res = await fm.fetchHandler('http://a.com'); expect(res.status).to.equal(200); - const blobData = await res.blob(); - expect(blobData).to.eql(blob); + const receivedData = await res.blob(); + expect(receivedData).to.eql(body); }); it('should set the url property on responses', async () => { From 908acbc622b797ecf2a1332e97d74e22ce00961c Mon Sep 17 00:00:00 2001 From: Rhys Evans Date: Thu, 29 Aug 2024 22:02:53 +0100 Subject: [PATCH 07/12] test: tests fotr all new body types pass --- .../FetchMock/response-construction.test.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/core/src/__tests__/FetchMock/response-construction.test.js b/packages/core/src/__tests__/FetchMock/response-construction.test.js index 4d2f8988..7be88aaf 100644 --- a/packages/core/src/__tests__/FetchMock/response-construction.test.js +++ b/packages/core/src/__tests__/FetchMock/response-construction.test.js @@ -131,8 +131,8 @@ describe('response construction', () => { } it('respond with Blob', async () => { - const blobParts = ['hey!']; - const body = new Blob(blobParts, { type: 'text/html' }); + 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); @@ -167,30 +167,32 @@ describe('response construction', () => { }); it('respond with ReadableStream', async () => { - const body = ReadableStream.from(['a', 'b']); + 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(); - console.log(receivedData); - expect(receivedData).to.eql(body); + expect(receivedData).to.eql('test value'); }); }); 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.blob(); + const receivedData = await res.formData(); + console.log(receivedData); expect(receivedData).to.eql(body); }); 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.blob(); - expect(receivedData).to.eql(body); + const receivedData = await res.formData(); + expect(receivedData.get('field')).to.equal('value'); }); it('should set the url property on responses', async () => { From ef4c684bddb617e95c54217c86b6953637f21f74 Mon Sep 17 00:00:00 2001 From: Rhys Evans Date: Thu, 29 Aug 2024 22:32:44 +0100 Subject: [PATCH 08/12] feat: send content=length header whenever calculable --- packages/core/src/Route.ts | 27 ++- .../FetchMock/response-construction.test.js | 155 +++++++++--------- 2 files changed, 101 insertions(+), 81 deletions(-) diff --git a/packages/core/src/Route.ts b/packages/core/src/Route.ts index 2aa2fb79..cb4405b5 100644 --- a/packages/core/src/Route.ts +++ b/packages/core/src/Route.ts @@ -228,16 +228,29 @@ class Route { } } - 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; } + responseOptions.headers.set('Content-Length', length.toString()); } - return body as BodyInit; } diff --git a/packages/core/src/__tests__/FetchMock/response-construction.test.js b/packages/core/src/__tests__/FetchMock/response-construction.test.js index 7be88aaf..15fae2d8 100644 --- a/packages/core/src/__tests__/FetchMock/response-construction.test.js +++ b/packages/core/src/__tests__/FetchMock/response-construction.test.js @@ -47,62 +47,6 @@ describe('response construction', () => { }); }); - 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('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('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('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('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('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('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('respond with a complex response, including headers', async () => { fm.route('*', { status: 202, @@ -138,6 +82,7 @@ describe('response construction', () => { 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('respond with ArrayBuffer', async () => { const body = str2ab('test value'); @@ -146,6 +91,7 @@ describe('response construction', () => { 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('respond with TypedArray', async () => { const buffer = str2ab('test value'); @@ -155,6 +101,7 @@ describe('response construction', () => { 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('respond with DataView', async () => { const buffer = str2ab('test value'); @@ -164,6 +111,7 @@ describe('response construction', () => { 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('respond with ReadableStream', async () => { @@ -173,26 +121,85 @@ describe('response construction', () => { 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('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(); - console.log(receivedData); - expect(receivedData).to.eql(body); - }); - 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'); + 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); + }); + 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://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('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('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('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('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('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('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 () => { From 398862a98d89f2803177aacf81790aff78cd56ee Mon Sep 17 00:00:00 2001 From: Rhys Evans Date: Fri, 30 Aug 2024 10:26:55 +0100 Subject: [PATCH 09/12] chore: use that BodyInit can be null in typedefs --- packages/core/src/Route.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/Route.ts b/packages/core/src/Route.ts index cb4405b5..19772881 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?: BodyInit | object | null; + body?: BodyInit | object ; status?: number; headers?: { [key: string]: string; @@ -72,7 +72,7 @@ export type RouteResponse = | RouteResponseFunction; export type RouteName = string; -function isBodyInit(body: BodyInit | null | object): body is BodyInit { +function isBodyInit(body: BodyInit | object): body is BodyInit { return ( body instanceof Blob || body instanceof ArrayBuffer || @@ -210,7 +210,7 @@ class Route { constructResponseBody( responseInput: RouteResponseConfig, responseOptions: ResponseInitUsingHeaders, - ): BodyInit | null { + ): BodyInit { let body = responseInput.body; const bodyIsBodyInit = isBodyInit(body); From edd401b0ebbcdb521873d72986bb24042794701d Mon Sep 17 00:00:00 2001 From: Rhys Evans Date: Fri, 30 Aug 2024 10:30:15 +0100 Subject: [PATCH 10/12] chore: lint --- packages/core/src/Route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/Route.ts b/packages/core/src/Route.ts index 19772881..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?: BodyInit | object ; + body?: BodyInit | object; status?: number; headers?: { [key: string]: string; From abe7203b7485ea6d5bf3c89403ed431a8a04ef69 Mon Sep 17 00:00:00 2001 From: Rhys Evans Date: Fri, 30 Aug 2024 10:33:54 +0100 Subject: [PATCH 11/12] docs: document when content-length gets added --- docs/docs/@fetch-mock/core/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/@fetch-mock/core/configuration.md b/docs/docs/@fetch-mock/core/configuration.md index 96773735..fee495a4 100644 --- a/docs/docs/@fetch-mock/core/configuration.md +++ b/docs/docs/@fetch-mock/core/configuration.md @@ -19,7 +19,7 @@ Options marked with a `†` can also be overridden for individual calls to `.moc `{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 From dde5e6beb9aee103296cf060a9f027bffb4818e9 Mon Sep 17 00:00:00 2001 From: Rhys Evans Date: Fri, 30 Aug 2024 10:57:36 +0100 Subject: [PATCH 12/12] fix: force engine to be >=18.11.0 as this fixes an issue in proxying a response --- .circleci/config.yml | 2 +- packages/core/package.json | 2 +- packages/core/src/Router.ts | 4 ++-- packages/vitest/package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) 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/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/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/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"