Skip to content

Commit

Permalink
Add no-invalid-fetch-options rule (#2338)
Browse files Browse the repository at this point in the history
  • Loading branch information
fisker authored May 8, 2024
1 parent 45bd444 commit 342aafb
Show file tree
Hide file tree
Showing 6 changed files with 526 additions and 0 deletions.
44 changes: 44 additions & 0 deletions docs/rules/no-invalid-fetch-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Disallow invalid options in `fetch()` and `new Request()`

💼 This rule is enabled in the ✅ `recommended` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#preset-configs-eslintconfigjs).

<!-- end auto-generated rule header -->
<!-- Do not manually modify this header. Run: `npm run fix:eslint-docs` -->

[`fetch()`](https://developer.mozilla.org/en-US/docs/Web/API/fetch) throws a `TypeError` when the method is `GET` or `HEAD` and a body is provided.

## Fail

```js
const response = await fetch('/', {body: 'foo=bar'});
```

```js
const request = new Request('/', {body: 'foo=bar'});
```

```js
const response = await fetch('/', {method: 'GET', body: 'foo=bar'});
```

```js
const request = new Request('/', {method: 'GET', body: 'foo=bar'});
```

## Pass

```js
const response = await fetch('/', {method: 'HEAD'});
```

```js
const request = new Request('/', {method: 'HEAD'});
```

```js
const response = await fetch('/', {method: 'POST', body: 'foo=bar'});
```

```js
const request = new Request('/', {method: 'POST', body: 'foo=bar'});
```
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ If you don't use the preset, ensure you use the same `env` and `parserOptions` c
| [no-for-loop](docs/rules/no-for-loop.md) | Do not use a `for` loop that can be replaced with a `for-of` loop. || 🔧 | 💡 |
| [no-hex-escape](docs/rules/no-hex-escape.md) | Enforce the use of Unicode escapes instead of hexadecimal escapes. || 🔧 | |
| [no-instanceof-array](docs/rules/no-instanceof-array.md) | Require `Array.isArray()` instead of `instanceof Array`. || 🔧 | |
| [no-invalid-fetch-options](docs/rules/no-invalid-fetch-options.md) | Disallow invalid options in `fetch()` and `new Request()`. || | |
| [no-invalid-remove-event-listener](docs/rules/no-invalid-remove-event-listener.md) | Prevent calling `EventTarget#removeEventListener()` with the result of an expression. || | |
| [no-keyword-prefix](docs/rules/no-keyword-prefix.md) | Disallow identifiers starting with `new` or `class`. | | | |
| [no-lonely-if](docs/rules/no-lonely-if.md) | Disallow `if` statements as the only statement in `if` blocks without `else`. || 🔧 | |
Expand Down
111 changes: 111 additions & 0 deletions rules/no-invalid-fetch-options.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
'use strict';
const {getStaticValue} = require('@eslint-community/eslint-utils');
const {
isCallExpression,
isNewExpression,
isUndefined,
isNullLiteral,
} = require('./ast/index.js');

const MESSAGE_ID_ERROR = 'no-invalid-fetch-options';
const messages = {
[MESSAGE_ID_ERROR]: '"body" is not allowed when method is "{{method}}".',
};

const isObjectPropertyWithName = (node, name) =>
node.type === 'Property'
&& !node.computed
&& node.key.type === 'Identifier'
&& node.key.name === name;

function checkFetchOptions(context, node) {
if (node.type !== 'ObjectExpression') {
return;
}

const {properties} = node;

const bodyProperty = properties.findLast(property => isObjectPropertyWithName(property, 'body'));

if (!bodyProperty) {
return;
}

const bodyValue = bodyProperty.value;
if (isUndefined(bodyValue) || isNullLiteral(bodyValue)) {
return;
}

const methodProperty = properties.findLast(property => isObjectPropertyWithName(property, 'method'));
// If `method` is omitted but there is an `SpreadElement`, we just ignore the case
if (!methodProperty) {
if (properties.some(node => node.type === 'SpreadElement')) {
return;
}

return {
node: bodyProperty.key,
messageId: MESSAGE_ID_ERROR,
data: {method: 'GET'},
};
}

const methodValue = methodProperty.value;

const scope = context.sourceCode.getScope(methodValue);
let method = getStaticValue(methodValue, scope)?.value;

if (typeof method !== 'string') {
return;
}

method = method.toUpperCase();
if (method !== 'GET' && method !== 'HEAD') {
return;
}

return {
node: bodyProperty.key,
messageId: MESSAGE_ID_ERROR,
data: {method},
};
}

/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('CallExpression', callExpression => {
if (!isCallExpression(callExpression, {
name: 'fetch',
minimumArguments: 2,
optional: false,
})) {
return;
}

return checkFetchOptions(context, callExpression.arguments[1]);
});

context.on('NewExpression', newExpression => {
if (!isNewExpression(newExpression, {
name: 'Request',
minimumArguments: 2,
})) {
return;
}

return checkFetchOptions(context, newExpression.arguments[1]);
});
};

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
create,
meta: {
type: 'problem',
docs: {
description: 'Disallow invalid options in `fetch()` and `new Request()`.',
recommended: true,
},
messages,
},
};
97 changes: 97 additions & 0 deletions test/no-invalid-fetch-options.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import outdent from 'outdent';
import {getTester} from './utils/test.mjs';

const {test} = getTester(import.meta);

test.snapshot({
valid: [
'fetch(url, {method: "POST", body})',
'new Request(url, {method: "POST", body})',
'fetch(url, {})',
'new Request(url, {})',
'fetch(url)',
'new Request(url)',
'fetch(url, {method: "UNKNOWN", body})',
'new Request(url, {method: "UNKNOWN", body})',
'fetch(url, {body: undefined})',
'new Request(url, {body: undefined})',
'fetch(url, {body: null})',
'new Request(url, {body: null})',
'fetch(url, {...options, body})',
'new Request(url, {...options, body})',
'new fetch(url, {body})',
'Request(url, {body})',
'not_fetch(url, {body})',
'new not_Request(url, {body})',
'fetch({body}, url)',
'new Request({body}, url)',
'fetch(url, {[body]: "foo=bar"})',
'new Request(url, {[body]: "foo=bar"})',
outdent`
fetch(url, {
body: 'foo=bar',
body: undefined,
});
`,
outdent`
new Request(url, {
body: 'foo=bar',
body: undefined,
});
`,
outdent`
fetch(url, {
method: 'HEAD',
body: 'foo=bar',
method: 'post',
});
`,
outdent`
new Request(url, {
method: 'HEAD',
body: 'foo=bar',
method: 'post',
});
`,
],
invalid: [
'fetch(url, {body})',
'new Request(url, {body})',
'fetch(url, {method: "GET", body})',
'new Request(url, {method: "GET", body})',
'fetch(url, {method: "HEAD", body})',
'new Request(url, {method: "HEAD", body})',
'fetch(url, {method: "head", body})',
'new Request(url, {method: "head", body})',
'const method = "head"; new Request(url, {method, body: "foo=bar"})',
'const method = "head"; fetch(url, {method, body: "foo=bar"})',
'fetch(url, {body}, extraArgument)',
'new Request(url, {body}, extraArgument)',
outdent`
fetch(url, {
body: undefined,
body: 'foo=bar',
});
`,
outdent`
new Request(url, {
body: undefined,
body: 'foo=bar',
});
`,
outdent`
fetch(url, {
method: 'post',
body: 'foo=bar',
method: 'HEAD',
});
`,
outdent`
new Request(url, {
method: 'post',
body: 'foo=bar',
method: 'HEAD',
});
`,
],
});
Loading

0 comments on commit 342aafb

Please sign in to comment.