diff --git a/docs/rules/no-invalid-fetch-options.md b/docs/rules/no-invalid-fetch-options.md new file mode 100644 index 0000000000..ea304d00d7 --- /dev/null +++ b/docs/rules/no-invalid-fetch-options.md @@ -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). + + + + +[`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'}); +``` diff --git a/readme.md b/readme.md index c5d88e7477..e10cde0bc5 100644 --- a/readme.md +++ b/readme.md @@ -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`. | ✅ | 🔧 | | diff --git a/rules/no-invalid-fetch-options.js b/rules/no-invalid-fetch-options.js new file mode 100644 index 0000000000..8989f0478e --- /dev/null +++ b/rules/no-invalid-fetch-options.js @@ -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, + }, +}; diff --git a/test/no-invalid-fetch-options.mjs b/test/no-invalid-fetch-options.mjs new file mode 100644 index 0000000000..5ed1f977d9 --- /dev/null +++ b/test/no-invalid-fetch-options.mjs @@ -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', + }); + `, + ], +}); diff --git a/test/snapshots/no-invalid-fetch-options.mjs.md b/test/snapshots/no-invalid-fetch-options.mjs.md new file mode 100644 index 0000000000..263c6dfd06 --- /dev/null +++ b/test/snapshots/no-invalid-fetch-options.mjs.md @@ -0,0 +1,273 @@ +# Snapshot report for `test/no-invalid-fetch-options.mjs` + +The actual snapshot is saved in `no-invalid-fetch-options.mjs.snap`. + +Generated by [AVA](https://avajs.dev). + +## invalid(1): fetch(url, {body}) + +> Input + + `␊ + 1 | fetch(url, {body})␊ + ` + +> Error 1/1 + + `␊ + > 1 | fetch(url, {body})␊ + | ^^^^ "body" is not allowed when method is "GET".␊ + ` + +## invalid(2): new Request(url, {body}) + +> Input + + `␊ + 1 | new Request(url, {body})␊ + ` + +> Error 1/1 + + `␊ + > 1 | new Request(url, {body})␊ + | ^^^^ "body" is not allowed when method is "GET".␊ + ` + +## invalid(3): fetch(url, {method: "GET", body}) + +> Input + + `␊ + 1 | fetch(url, {method: "GET", body})␊ + ` + +> Error 1/1 + + `␊ + > 1 | fetch(url, {method: "GET", body})␊ + | ^^^^ "body" is not allowed when method is "GET".␊ + ` + +## invalid(4): new Request(url, {method: "GET", body}) + +> Input + + `␊ + 1 | new Request(url, {method: "GET", body})␊ + ` + +> Error 1/1 + + `␊ + > 1 | new Request(url, {method: "GET", body})␊ + | ^^^^ "body" is not allowed when method is "GET".␊ + ` + +## invalid(5): fetch(url, {method: "HEAD", body}) + +> Input + + `␊ + 1 | fetch(url, {method: "HEAD", body})␊ + ` + +> Error 1/1 + + `␊ + > 1 | fetch(url, {method: "HEAD", body})␊ + | ^^^^ "body" is not allowed when method is "HEAD".␊ + ` + +## invalid(6): new Request(url, {method: "HEAD", body}) + +> Input + + `␊ + 1 | new Request(url, {method: "HEAD", body})␊ + ` + +> Error 1/1 + + `␊ + > 1 | new Request(url, {method: "HEAD", body})␊ + | ^^^^ "body" is not allowed when method is "HEAD".␊ + ` + +## invalid(7): fetch(url, {method: "head", body}) + +> Input + + `␊ + 1 | fetch(url, {method: "head", body})␊ + ` + +> Error 1/1 + + `␊ + > 1 | fetch(url, {method: "head", body})␊ + | ^^^^ "body" is not allowed when method is "HEAD".␊ + ` + +## invalid(8): new Request(url, {method: "head", body}) + +> Input + + `␊ + 1 | new Request(url, {method: "head", body})␊ + ` + +> Error 1/1 + + `␊ + > 1 | new Request(url, {method: "head", body})␊ + | ^^^^ "body" is not allowed when method is "HEAD".␊ + ` + +## invalid(9): const method = "head"; new Request(url, {method, body: "foo=bar"}) + +> Input + + `␊ + 1 | const method = "head"; new Request(url, {method, body: "foo=bar"})␊ + ` + +> Error 1/1 + + `␊ + > 1 | const method = "head"; new Request(url, {method, body: "foo=bar"})␊ + | ^^^^ "body" is not allowed when method is "HEAD".␊ + ` + +## invalid(10): const method = "head"; fetch(url, {method, body: "foo=bar"}) + +> Input + + `␊ + 1 | const method = "head"; fetch(url, {method, body: "foo=bar"})␊ + ` + +> Error 1/1 + + `␊ + > 1 | const method = "head"; fetch(url, {method, body: "foo=bar"})␊ + | ^^^^ "body" is not allowed when method is "HEAD".␊ + ` + +## invalid(11): fetch(url, {body}, extraArgument) + +> Input + + `␊ + 1 | fetch(url, {body}, extraArgument)␊ + ` + +> Error 1/1 + + `␊ + > 1 | fetch(url, {body}, extraArgument)␊ + | ^^^^ "body" is not allowed when method is "GET".␊ + ` + +## invalid(12): new Request(url, {body}, extraArgument) + +> Input + + `␊ + 1 | new Request(url, {body}, extraArgument)␊ + ` + +> Error 1/1 + + `␊ + > 1 | new Request(url, {body}, extraArgument)␊ + | ^^^^ "body" is not allowed when method is "GET".␊ + ` + +## invalid(13): fetch(url, { body: undefined, body: 'foo=bar', }); + +> Input + + `␊ + 1 | fetch(url, {␊ + 2 | body: undefined,␊ + 3 | body: 'foo=bar',␊ + 4 | });␊ + ` + +> Error 1/1 + + `␊ + 1 | fetch(url, {␊ + 2 | body: undefined,␊ + > 3 | body: 'foo=bar',␊ + | ^^^^ "body" is not allowed when method is "GET".␊ + 4 | });␊ + ` + +## invalid(14): new Request(url, { body: undefined, body: 'foo=bar', }); + +> Input + + `␊ + 1 | new Request(url, {␊ + 2 | body: undefined,␊ + 3 | body: 'foo=bar',␊ + 4 | });␊ + ` + +> Error 1/1 + + `␊ + 1 | new Request(url, {␊ + 2 | body: undefined,␊ + > 3 | body: 'foo=bar',␊ + | ^^^^ "body" is not allowed when method is "GET".␊ + 4 | });␊ + ` + +## invalid(15): fetch(url, { method: 'post', body: 'foo=bar', method: 'HEAD', }); + +> Input + + `␊ + 1 | fetch(url, {␊ + 2 | method: 'post',␊ + 3 | body: 'foo=bar',␊ + 4 | method: 'HEAD',␊ + 5 | });␊ + ` + +> Error 1/1 + + `␊ + 1 | fetch(url, {␊ + 2 | method: 'post',␊ + > 3 | body: 'foo=bar',␊ + | ^^^^ "body" is not allowed when method is "HEAD".␊ + 4 | method: 'HEAD',␊ + 5 | });␊ + ` + +## invalid(16): new Request(url, { method: 'post', body: 'foo=bar', method: 'HEAD', }); + +> Input + + `␊ + 1 | new Request(url, {␊ + 2 | method: 'post',␊ + 3 | body: 'foo=bar',␊ + 4 | method: 'HEAD',␊ + 5 | });␊ + ` + +> Error 1/1 + + `␊ + 1 | new Request(url, {␊ + 2 | method: 'post',␊ + > 3 | body: 'foo=bar',␊ + | ^^^^ "body" is not allowed when method is "HEAD".␊ + 4 | method: 'HEAD',␊ + 5 | });␊ + ` diff --git a/test/snapshots/no-invalid-fetch-options.mjs.snap b/test/snapshots/no-invalid-fetch-options.mjs.snap new file mode 100644 index 0000000000..a41b2288ea Binary files /dev/null and b/test/snapshots/no-invalid-fetch-options.mjs.snap differ