From 342aafb3725663cd7da80ab59d3ed9c50800c9e6 Mon Sep 17 00:00:00 2001 From: fisker Cheung Date: Thu, 9 May 2024 03:20:58 +0800 Subject: [PATCH] Add `no-invalid-fetch-options` rule (#2338) --- docs/rules/no-invalid-fetch-options.md | 44 +++ readme.md | 1 + rules/no-invalid-fetch-options.js | 111 +++++++ test/no-invalid-fetch-options.mjs | 97 +++++++ .../snapshots/no-invalid-fetch-options.mjs.md | 273 ++++++++++++++++++ .../no-invalid-fetch-options.mjs.snap | Bin 0 -> 841 bytes 6 files changed, 526 insertions(+) create mode 100644 docs/rules/no-invalid-fetch-options.md create mode 100644 rules/no-invalid-fetch-options.js create mode 100644 test/no-invalid-fetch-options.mjs create mode 100644 test/snapshots/no-invalid-fetch-options.mjs.md create mode 100644 test/snapshots/no-invalid-fetch-options.mjs.snap 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 0000000000000000000000000000000000000000..a41b2288eaab70f86aa8ec096a629634549297fc GIT binary patch literal 841 zcmV-P1GfA@RzVx-Kt?~&B=(IHN{w|# z!G{3;Zy$>Y00000000Bcm%ne*KorMQ5JK_B%7C6Mq{Jwsb(7MPDnXSRK^Zz!89G2D zC%zC<*ADiD1e!816oERhfP@eOWnf_fi9dk944K$&pMB>asRLw;bKiIG^ZlN4?{lxC zo83M8?Fpe8)yeppW;|7NO)bh&4SIxj`^BN9m*Ajds?QE3%{G*Q-8ZTI@7!0Yqb`Pz6C^UF$0RSd&_S0^++aNlRMbK<8 zObUvwn&Li<|vn7_@@>HYz!YMkd5xqF7`b3e z@fc+ftKz|`GE3u-Sdy^n}X(48f(huJ% zFMNHXsM&-MDV;NXSnHpJZ!J1}G2BvzFP2{V@TG;AHOg%d9%Vi_Dr+hQ7n-Kc^5&x??OI*B^Lf)2>%Zce+I>p8Z$Saw*2ex65pusF->IA4zg%7 z+M+}?Smyjh=6sDYCs~=)w#?zJEi(s{5|A;qlnv|AaGw}-rfVuwh=uk!lG1tOOGqk= zFS8q!|B99)#+i6XNiU{}Q5_+{arCg009eM@?)cb(!;hQ(p`ntVW)S~46x`!bD8Zq$ z9wztzHN4G;nEdVNnF>>s2Y>MaU+jr#K Tm-r*~|GD=Y(WP2!TNMBR^Pia| literal 0 HcmV?d00001