Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: handle all types of BodyInit correctly #824

Merged
merged 12 commits into from
Aug 30, 2024
Merged
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 1 addition & 7 deletions docs/docs/@fetch-mock/core/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<sup>†</sup>

`{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<sup>†</sup>

`{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

Expand Down
18 changes: 14 additions & 4 deletions docs/docs/@fetch-mock/core/route/response.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
20 changes: 10 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 0 additions & 2 deletions packages/core/src/FetchMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
63 changes: 48 additions & 15 deletions packages/core/src/Route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading