diff --git a/.github/workflows/publish-beta.yml b/.github/workflows/publish-beta.yml index 22eab1a..a69ed85 100644 --- a/.github/workflows/publish-beta.yml +++ b/.github/workflows/publish-beta.yml @@ -14,10 +14,11 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} - - name: Dependency Review - uses: actions/dependency-review-action@v4 - with: - allow-licenses: ${{ vars.ALLOW_LICENSES }} + # - name: Dependency Review + # uses: actions/dependency-review-action@v4 + # with: + # allow-licenses: ${{ vars.ALLOW_LICENSES }} + # allow-dependencies-licenses: pkg:npm/flatted - name: Setup Node.js uses: actions/setup-node@v4 with: diff --git a/docs/rules/no-fixture.md b/docs/rules/no-fixture.md new file mode 100644 index 0000000..ac9fead --- /dev/null +++ b/docs/rules/no-fixture.md @@ -0,0 +1,25 @@ +# To ease the transition from fixture to native fetch API, this rule convert relevant code automatically. + +## Before + +```js +it('returns current server time', async () => { + const response = await fixture.api.get(`/sample-service/v1/ping`).expect(StatusCodes.OK); + const body = response.body; + const timeDifference = Date.now() - new Date(body.serverTime).getTime(); + assert.ok(timeDifference >= 0 && timeDifference < 200); +}); +``` + +## After + +```js +it('returns current server time', async () => { + // assume the existence of - const BASE_PATH = 'https://sample-service.checkdigit/sample-service/v1'; + const response = await fetch(`${BASE_PATH}/ping`); + assert.equal(response.status, StatusCodes.OK); + const body = await response.json(); + const timeDifference = Date.now() - new Date(body.serverTime).getTime(); + assert.ok(timeDifference >= 0 && timeDifference < 200); +}); +``` diff --git a/package-lock.json b/package-lock.json index eb3427c..c057fe0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "@checkdigit/eslint-plugin", - "version": "7.5.0", + "version": "7.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@checkdigit/eslint-plugin", - "version": "7.5.0", + "version": "7.6.0", "license": "MIT", "dependencies": { "@typescript-eslint/type-utils": "^8.15.0", "@typescript-eslint/utils": "^8.15.0", + "debug": "^4.3.7", "ts-api-utils": "^1.4.0" }, "devDependencies": { @@ -18,6 +19,7 @@ "@checkdigit/prettier-config": "^5.5.1", "@checkdigit/typescript-config": "^8.0.0", "@eslint/js": "^9.15.0", + "@types/debug": "^4.1.12", "@types/eslint": "^9.6.1", "@types/eslint-config-prettier": "^6.11.3", "@typescript-eslint/parser": "^8.15.0", @@ -1111,10 +1113,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz", - "integrity": "sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==", - "dev": true, + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" @@ -2053,6 +2054,16 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -2131,10 +2142,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { - "version": "22.9.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", - "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", + "version": "22.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.1.tgz", + "integrity": "sha512-p8Yy/8sw1caA8CdRIQBG5tiLHmxtQKObCijiAa9Ez+d4+PRffM4054xbju0msf+cvhJpnFEeNjxmVT/0ipktrg==", "dev": true, "license": "MIT", "peer": true, @@ -2946,9 +2964,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001678", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001678.tgz", - "integrity": "sha512-RR+4U/05gNtps58PEBDZcPWTgEO2MBeoPZ96aQcjmfkBWRIDfN451fW2qyDA9/+HohLLIL5GqiMwA+IB1pWarw==", + "version": "1.0.30001680", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz", + "integrity": "sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==", "dev": true, "funding": [ { @@ -3343,9 +3361,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.53", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.53.tgz", - "integrity": "sha512-7F6qFMWzBArEFK4PLE+c+nWzhS1kIoNkQvGnNDogofxQAym+roQ0GUIdw6C/4YdJ6JKGp19c2a/DLcfKTi4wRQ==", + "version": "1.5.63", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.63.tgz", + "integrity": "sha512-ddeXKuY9BHo/mw145axlyWjlJ1UBt4WK3AlvkT7W2AbqfRQoacVoRUCF6wL3uIx/8wT9oLKXzI+rFqHHscByaA==", "dev": true, "license": "ISC", "peer": true @@ -3397,9 +3415,9 @@ } }, "node_modules/es-abstract": { - "version": "1.23.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", - "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "version": "1.23.5", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.5.tgz", + "integrity": "sha512-vlmniQ0WNPwXqA0BnmwV3Ng7HxiGlh6r5U6JcTMNx8OilcAGqVJBHJcPjqOMaczU9fRuRK5Px2BdVyPRnKMMVQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3418,7 +3436,7 @@ "function.prototype.name": "^1.1.6", "get-intrinsic": "^1.2.4", "get-symbol-description": "^1.0.2", - "globalthis": "^1.0.3", + "globalthis": "^1.0.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2", "has-proto": "^1.0.3", @@ -3434,10 +3452,10 @@ "is-string": "^1.0.7", "is-typed-array": "^1.1.13", "is-weakref": "^1.0.2", - "object-inspect": "^1.13.1", + "object-inspect": "^1.13.3", "object-keys": "^1.1.1", "object.assign": "^4.1.5", - "regexp.prototype.flags": "^1.5.2", + "regexp.prototype.flags": "^1.5.3", "safe-array-concat": "^1.1.2", "safe-regex-test": "^1.0.3", "string.prototype.trim": "^1.2.9", @@ -4019,15 +4037,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -4341,9 +4350,9 @@ } }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", + "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", "license": "ISC" }, "node_modules/for-each": { @@ -5149,14 +5158,14 @@ } }, "node_modules/is-reference": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz", - "integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@types/estree": "*" + "@types/estree": "^1.0.6" } }, "node_modules/is-regex": { @@ -6158,9 +6167,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.12", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", - "integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==", + "version": "0.30.13", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.13.tgz", + "integrity": "sha512-8rYBO+MsWkgjDSOvLomYnzhdwEG51olQ4zL5KXnNJWV5MNmrb4rTZdrtkhxjnD/QyZUqR/Z/XDsUs/4ej2nx0g==", "dev": true, "license": "MIT", "peer": true, @@ -6334,9 +6343,9 @@ } }, "node_modules/object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", "dev": true, "license": "MIT", "engines": { @@ -7626,9 +7635,9 @@ } }, "node_modules/svelte": { - "version": "5.1.12", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.1.12.tgz", - "integrity": "sha512-U9BwbSybb9QAKAHg4hl61hVBk97U2QjUKmZa5++QEGoi6Nml6x6cC9KmNT1XObGawToN3DdLpdCs/Z5Yl5IXjQ==", + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.2.6.tgz", + "integrity": "sha512-mQBm268315W4lu6LxJK0Nt3Ou/m2uFJTKzLcFWhTTiA7cbCvVUeqwQSEBkGpbxQw84VCSO6my7DUlWsSy1/tQA==", "dev": true, "license": "MIT", "peer": true, @@ -7642,7 +7651,7 @@ "axobject-query": "^4.1.0", "esm-env": "^1.0.0", "esrap": "^1.2.2", - "is-reference": "^3.0.2", + "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" diff --git a/package.json b/package.json index 5d064fd..2934ed1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@checkdigit/eslint-plugin", - "version": "7.5.0", + "version": "7.6.0", "description": "Check Digit eslint plugins", "keywords": [ "eslint", @@ -63,6 +63,7 @@ "dependencies": { "@typescript-eslint/type-utils": "^8.15.0", "@typescript-eslint/utils": "^8.15.0", + "debug": "^4.3.7", "ts-api-utils": "^1.4.0" }, "devDependencies": { @@ -70,6 +71,7 @@ "@checkdigit/prettier-config": "^5.5.1", "@checkdigit/typescript-config": "^8.0.0", "@eslint/js": "^9.15.0", + "@types/debug": "^4.1.12", "@types/eslint": "^9.6.1", "@types/eslint-config-prettier": "^6.11.3", "@typescript-eslint/parser": "^8.15.0", diff --git a/src/agent/add-assert-import.spec.ts b/src/agent/add-assert-import.spec.ts new file mode 100644 index 0000000..50bb05e --- /dev/null +++ b/src/agent/add-assert-import.spec.ts @@ -0,0 +1,54 @@ +// agent/add-url-domain.spec.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import createTester from '../ts-tester.test'; +import rule, { ruleId } from './add-assert-import'; + +createTester().run(ruleId, rule, { + valid: [ + { + name: 'no change if the assert is not used', + code: `const name='abc';`, + }, + { + name: 'no change if the assert is used and imported', + code: `import { strict as assert } from 'node:assert'; + assert(true);`, + }, + { + name: 'no change if the assert is used and imported - not using node: prefix in the module name', + code: `import { strict as assert } from 'assert'; + assert(true);`, + }, + ], + invalid: [ + { + name: 'add assert import if assert is used but not imported', + code: `assert(true);`, + output: `import { strict as assert } from 'node:assert'; +assert(true);`, + errors: [{ messageId: 'addAssertImport' }], + }, + { + name: 'add assert import if assert is used but not imported - using assert.ok', + code: `assert.ok(true);`, + output: `import { strict as assert } from 'node:assert'; +assert.ok(true);`, + errors: [{ messageId: 'addAssertImport' }], + }, + { + name: 'add assert import if assert is used but not imported - with first line as comment', + code: `// api/v1/ping.spec.ts + assert.ok(true);`, + output: `// api/v1/ping.spec.ts + import { strict as assert } from 'node:assert'; +assert.ok(true);`, + errors: [{ messageId: 'addAssertImport' }], + }, + ], +}); diff --git a/src/agent/add-assert-import.ts b/src/agent/add-assert-import.ts new file mode 100644 index 0000000..d50b905 --- /dev/null +++ b/src/agent/add-assert-import.ts @@ -0,0 +1,74 @@ +// agent/add-url-domain.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { strict as assert } from 'node:assert'; + +import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'; + +import getDocumentationUrl from '../get-documentation-url'; + +export const ruleId = 'add-assert-import'; + +const ASSERT_IMPORT_STATEMENT = "import { strict as assert } from 'node:assert';"; + +const createRule = ESLintUtils.RuleCreator((name) => getDocumentationUrl(name)); + +const rule: ESLintUtils.RuleModule<'addAssertImport'> = createRule({ + name: ruleId, + meta: { + type: 'suggestion', + docs: { + description: 'Add import of assert module of node.', + }, + messages: { + addAssertImport: 'Add import of assert module of node.', + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + let isAssertImported = false; + let isAssertUsed = false; + + return { + ImportDeclaration: (node) => { + if (node.source.value === 'assert' || node.source.value === 'node:assert') { + isAssertImported = true; + } + }, + CallExpression: (callExpression) => { + // detect if assert is used + if ( + (callExpression.callee.type === AST_NODE_TYPES.Identifier && callExpression.callee.name === 'assert') || + (callExpression.callee.type === AST_NODE_TYPES.MemberExpression && + callExpression.callee.object.type === AST_NODE_TYPES.Identifier && + callExpression.callee.object.name === 'assert') + ) { + isAssertUsed = true; + } + }, + 'Program:exit': (program) => { + // add assert import if necessary + if (isAssertUsed && !isAssertImported) { + const firstStatement = program.body[0]; + assert(firstStatement); + context.report({ + node: program, + messageId: 'addAssertImport', + fix(fixer) { + return fixer.insertTextBefore(firstStatement, `${ASSERT_IMPORT_STATEMENT}\n`); + }, + }); + } + }, + }; + }, +}); + +export default rule; diff --git a/src/agent/add-base-path-const.spec.ts b/src/agent/add-base-path-const.spec.ts new file mode 100644 index 0000000..c1c64e8 --- /dev/null +++ b/src/agent/add-base-path-const.spec.ts @@ -0,0 +1,41 @@ +// agent/add-url-domain.spec.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import createTester from '../ts-tester.test'; +import rule, { ruleId } from './add-base-path-const'; + +createTester().run(ruleId, rule, { + valid: [ + { + name: 'no change if the BASE_PATH const is already declared', + filename: 'src/api/v1/index.ts', + code: `import ping from './ping'; + export const BASE_PATH = 'https://ping.checkdigit/ping/v1';`, + }, + ], + invalid: [ + { + name: 'add BASE_PATH const', + filename: 'src/api/v1/index.ts', + code: `import ping from './ping';`, + output: `import ping from './ping'; +export const BASE_PATH = 'https://ping.checkdigit/ping/v1'; +`, + errors: [{ messageId: 'addBasePathConst' }], + }, + { + name: 'add BASE_PATH const for swagger 2.0', + filename: 'src/api/v2/index.ts', + code: `import ping from './ping';`, + output: `import ping from './ping'; +export const BASE_PATH = 'https://ping.checkdigit/ping/v2'; +`, + errors: [{ messageId: 'addBasePathConst' }], + }, + ], +}); diff --git a/src/agent/add-base-path-const.ts b/src/agent/add-base-path-const.ts new file mode 100644 index 0000000..3e2efbe --- /dev/null +++ b/src/agent/add-base-path-const.ts @@ -0,0 +1,81 @@ +// agent/add-url-domain.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { strict as assert } from 'node:assert'; + +import { AST_NODE_TYPES, ESLintUtils, TSESTree } from '@typescript-eslint/utils'; + +import getDocumentationUrl from '../get-documentation-url'; +import { getProjectRootFolder, getSwaggerPathByIndexFile, isApiIndexFile, loadPackageJson, loadSwagger } from './file'; + +export const ruleId = 'add-base-path-const'; + +const createRule = ESLintUtils.RuleCreator((name) => getDocumentationUrl(name)); + +const rule: ESLintUtils.RuleModule<'addBasePathConst'> = createRule({ + name: ruleId, + meta: { + type: 'suggestion', + docs: { + description: 'Add BASE_PATH const variable.', + }, + messages: { + addBasePathConst: 'Add BASE_PATH const variable.', + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + + return { + Program: (program: TSESTree.Program) => { + if (!isApiIndexFile(context.filename)) { + return; + } + + const scope = sourceCode.getScope(program).childScopes[0]; + assert(scope); + + const foundBasePathConst = scope.variables.find((variable) => variable.name === 'BASE_PATH'); + if (foundBasePathConst) { + return; + } + + const swaggerPath = getSwaggerPathByIndexFile(context.filename); + const swaggerFileContents = loadSwagger(swaggerPath); + const baseUrlLine = swaggerFileContents + .split('\n') + .find((line) => /^\s*-\s*url:\s*\/.*$/u.test(line) || /^basePath:.*/u.test(line)); + const baseUrl = baseUrlLine?.split(':')[1]?.trim(); + assert(baseUrl !== undefined); + + const packageRoot = getProjectRootFolder(context.filename); + const packageJson = JSON.parse(loadPackageJson(packageRoot)) as { name: string }; + const serviceName = packageJson.name.split('/')[1]; + assert(serviceName !== undefined); + + const domain = `https://${serviceName}.checkdigit${baseUrl}`; + + const lastImportStatement = program.body.findLast((node) => node.type === AST_NODE_TYPES.ImportDeclaration); + assert(lastImportStatement); + + context.report({ + messageId: 'addBasePathConst', + node: program, + fix(fixer) { + return fixer.insertTextAfter(lastImportStatement, `\nexport const BASE_PATH = '${domain}';\n`); + }, + }); + }, + }; + }, +}); + +export default rule; diff --git a/src/agent/add-base-path-import.spec.ts b/src/agent/add-base-path-import.spec.ts new file mode 100644 index 0000000..245c7ca --- /dev/null +++ b/src/agent/add-base-path-import.spec.ts @@ -0,0 +1,54 @@ +// agent/add-url-domain.spec.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import createTester from '../ts-tester.test'; +import rule, { ruleId } from './add-base-path-import'; + +createTester().run(ruleId, rule, { + valid: [ + { + name: 'no change if the BASE_PATH const is not used', + code: `const abc = '';`, + }, + { + name: 'no change if the BASE_PATH const is already declared', + filename: 'src/api/v1/index.ts', + code: ` + export const BASE_PATH = 'https://ping.checkdigit/ping/v1'; + await fixture.api.get(\`\${BASE_PATH}/ping\`).expect(StatusCodes.OK); + `, + }, + { + name: 'do not add missing import of BASE_PATH if api folder can not be determined', + code: `await fixture.api.get(\`\${BASE_PATH}/ping\`).expect(StatusCodes.OK);`, + }, + ], + invalid: [ + { + name: 'add missing import of BASE_PATH', + filename: 'src/api/v1/ping.spec.ts', + code: ` + import { strict as assert } from 'node:assert'; + const response = await fetch(\`\${BASE_PATH}/ping\`, { + method: 'GET', + }); + assert.equal(response.status, StatusCodes.OK); + `, + output: ` + import { strict as assert } from 'node:assert'; +import { BASE_PATH } from './index'; + + const response = await fetch(\`\${BASE_PATH}/ping\`, { + method: 'GET', + }); + assert.equal(response.status, StatusCodes.OK); + `, + errors: [{ messageId: 'addBasePathImport' }], + }, + ], +}); diff --git a/src/agent/add-base-path-import.ts b/src/agent/add-base-path-import.ts new file mode 100644 index 0000000..1901563 --- /dev/null +++ b/src/agent/add-base-path-import.ts @@ -0,0 +1,69 @@ +// agent/add-url-domain.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { strict as assert } from 'node:assert'; + +import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'; + +import getDocumentationUrl from '../get-documentation-url'; +import { getApiIndexPathByFilename } from './file'; + +export const ruleId = 'add-base-path-import'; + +const createRule = ESLintUtils.RuleCreator((name) => getDocumentationUrl(name)); + +const rule: ESLintUtils.RuleModule<'addBasePathImport'> = createRule({ + name: ruleId, + meta: { + type: 'suggestion', + docs: { + description: 'Add import of BASE_PATH if it is used but not imported.', + }, + messages: { + addBasePathImport: 'Add import of BASE_PATH.', + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + + return { + Program: (program) => { + const isBasePathUsed = sourceCode.text.includes(`$\{BASE_PATH}`); + if (isBasePathUsed) { + const topScope = sourceCode.getScope(program).childScopes[0]; + assert(topScope); + if (topScope.variables.some((variable) => variable.name === 'BASE_PATH')) { + return; + } + + const apiIndexPath = getApiIndexPathByFilename(context.filename); + if (apiIndexPath !== undefined) { + const lastImportStatement = program.body.findLast( + (statement) => statement.type === AST_NODE_TYPES.ImportDeclaration, + ); + assert(lastImportStatement); + + const basePathImportStatement = `\nimport { BASE_PATH } from '${apiIndexPath}';\n`; + context.report({ + node: program, + messageId: 'addBasePathImport', + fix(fixer) { + return fixer.insertTextAfter(lastImportStatement, basePathImportStatement); + }, + }); + } + } + }, + }; + }, +}); + +export default rule; diff --git a/src/agent/add-url-domain.spec.ts b/src/agent/add-url-domain.spec.ts new file mode 100644 index 0000000..28680d9 --- /dev/null +++ b/src/agent/add-url-domain.spec.ts @@ -0,0 +1,55 @@ +// agent/add-url-domain.spec.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +// import createTester from '../ts-tester.test'; +import { RuleTester } from '@typescript-eslint/rule-tester'; +import tsParser from '@typescript-eslint/parser'; +import rule, { ruleId } from './add-url-domain'; + +// createTester() + +const ruleTester = new RuleTester({ + languageOptions: { + parser: tsParser, + ecmaVersion: 'latest', + sourceType: 'module', + parserOptions: { + project: '../tsconfig.json', + tsconfigRootDir: `${process.cwd()}/ts-init`, + }, + }, +}); + +ruleTester.run(ruleId, rule, { + valid: [ + { + name: 'no change if the url already has domain', + code: `export const BASE_PATH = 'https://ping.checkdigit/ping/v1';`, + }, + ], + invalid: [ + { + name: 'add domain to url constant variable BASE_PATH as string', + code: `export const BASE_PATH = '/ping/v1';`, + output: `export const BASE_PATH = 'https://ping.checkdigit/ping/v1';`, + errors: [{ messageId: 'addDomain' }], + }, + { + name: 'add domain to url constant variable BASE_PATH as template literal', + code: `export const BASE_PATH = \`/ping/v1\`;`, + output: `export const BASE_PATH = \`https://ping.checkdigit/ping/v1\`;`, + errors: [{ messageId: 'addDomain' }], + }, + { + name: 'add domain to BASE_PATH like url constant variable', + code: `const FOO_BAR_BASE_PATH = '/foo-bar/v1';`, + output: `const FOO_BAR_BASE_PATH = 'https://foo-bar.checkdigit/foo-bar/v1';`, + errors: [{ messageId: 'addDomain' }], + }, + ], +}); diff --git a/src/agent/add-url-domain.ts b/src/agent/add-url-domain.ts new file mode 100644 index 0000000..e80222f --- /dev/null +++ b/src/agent/add-url-domain.ts @@ -0,0 +1,76 @@ +// agent/add-url-domain.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { AST_NODE_TYPES, ESLintUtils, TSESTree } from '@typescript-eslint/utils'; + +import getDocumentationUrl from '../get-documentation-url'; +import { addBasePathUrlDomain } from './url'; + +export const ruleId = 'add-url-domain'; + +const createRule = ESLintUtils.RuleCreator((name) => getDocumentationUrl(name)); + +const rule: ESLintUtils.RuleModule<'addDomain' | 'unknownError'> = createRule({ + name: ruleId, + meta: { + type: 'suggestion', + docs: { + description: 'Add HTTP domain to the BASE_PATH like url constant variable.', + }, + messages: { + addDomain: 'Add HTTP domain to the BASE_PATH like url constant variable.', + unknownError: 'Unknown error occurred in file "{{fileName}}": {{ error }}.', + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + + return { + 'VariableDeclarator[id.name=/^([A-Z]+_)*BASE_PATH$/]': (basePathDeclarator: TSESTree.VariableDeclarator) => { + try { + if ( + basePathDeclarator.init === null || + (basePathDeclarator.init.type !== AST_NODE_TYPES.Literal && + basePathDeclarator.init.type !== AST_NODE_TYPES.TemplateLiteral) + ) { + return; + } + + const urlText = sourceCode.getText(basePathDeclarator.init); + const replacement = addBasePathUrlDomain(urlText); + + if (replacement !== urlText) { + context.report({ + messageId: 'addDomain', + node: basePathDeclarator.init, + fix(fixer) { + return fixer.replaceText(basePathDeclarator.init as TSESTree.Node, replacement); + }, + }); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to apply ${ruleId} rule for file "${context.filename}":`, error); + context.report({ + node: basePathDeclarator, + messageId: 'unknownError', + data: { + fileName: context.filename, + error: error instanceof Error ? error.toString() : JSON.stringify(error), + }, + }); + } + }, + }; + }, +}); + +export default rule; diff --git a/src/agent/agent-test-wiring.spec.ts b/src/agent/agent-test-wiring.spec.ts new file mode 100644 index 0000000..a88577e --- /dev/null +++ b/src/agent/agent-test-wiring.spec.ts @@ -0,0 +1,271 @@ +// agent/agent-test-wiring.spec.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import createTester from '../ts-tester.test'; +import rule, { ruleId } from './agent-test-wiring'; + +createTester().run(ruleId, rule, { + valid: [ + { + name: 'no test wiring needed', + code: ` +describe('/ping', () => { + beforeAll(async () => { + // + }); + + it('test something', async () => { + // + }); +}); + `, + }, + ], + invalid: [ + { + name: 'update test wiring - async arrow function with body block', + filename: `src/api/v1/ping.spec.ts`, + code: ` +import { beforeAll, describe, it } from '@jest/globals'; +import { amazonSetup } from '@checkdigit/amazon'; +import { createFixture } from '@checkdigit/fixture'; +describe('/ping', () => { + const fixture = createFixture(amazonSetup); + beforeAll(async () => { + await fixture.reset(); + }, 15_000); +}); + `, + output: ` +import { afterAll, beforeAll, describe, it } from '@jest/globals'; +import { amazonSetup } from '@checkdigit/amazon'; +import { createFixture } from '@checkdigit/fixture'; +import createAgent, { type Agent } from '@checkdigit/agent'; +import fixturePlugin from '../../plugin/fixture.test'; +describe('/ping', () => { + const fixture = createFixture(amazonSetup); + let agent: Agent; +beforeAll(async () => { + agent = await createAgent(); +agent.register(await fixturePlugin(fixture)); +agent.enable(); +await fixture.reset(); + }, 15_000); +afterAll(async () => { +await agent[Symbol.asyncDispose](); +}); +}); + `, + errors: [{ messageId: 'updateTestWiring' }], + }, + { + name: 'update test wiring - function reference instead of arrow function', + filename: `src/api/v1/ping.spec.ts`, + code: ` +import { beforeAll, describe, it } from '@jest/globals'; +import { amazonSetup } from '@checkdigit/amazon'; +import { createFixture } from '@checkdigit/fixture'; +describe('/ping', () => { + const fixture = createFixture(amazonSetup); + beforeAll(fixture.reset); +}); + `, + output: ` +import { afterAll, beforeAll, describe, it } from '@jest/globals'; +import { amazonSetup } from '@checkdigit/amazon'; +import { createFixture } from '@checkdigit/fixture'; +import createAgent, { type Agent } from '@checkdigit/agent'; +import fixturePlugin from '../../plugin/fixture.test'; +describe('/ping', () => { + const fixture = createFixture(amazonSetup); + let agent: Agent; +beforeAll(async () => { +agent = await createAgent(); +agent.register(await fixturePlugin(fixture)); +agent.enable(); +await fixture.reset(); +}); +afterAll(async () => { +await agent[Symbol.asyncDispose](); +}); +}); + `, + errors: [{ messageId: 'updateTestWiring' }], + }, + { + name: 'update test wiring - function call instead of block - async', + filename: `src/api/v1/ping.spec.ts`, + code: ` +import { beforeAll, describe, it } from '@jest/globals'; +import { amazonSetup } from '@checkdigit/amazon'; +import { createFixture } from '@checkdigit/fixture'; +describe('/ping', () => { + const fixture = createFixture(amazonSetup); + beforeAll(async () => fixture.reset()); +}); + `, + output: ` +import { afterAll, beforeAll, describe, it } from '@jest/globals'; +import { amazonSetup } from '@checkdigit/amazon'; +import { createFixture } from '@checkdigit/fixture'; +import createAgent, { type Agent } from '@checkdigit/agent'; +import fixturePlugin from '../../plugin/fixture.test'; +describe('/ping', () => { + const fixture = createFixture(amazonSetup); + let agent: Agent; +beforeAll(async () => { +agent = await createAgent(); +agent.register(await fixturePlugin(fixture)); +agent.enable(); +await fixture.reset(); +}); +afterAll(async () => { +await agent[Symbol.asyncDispose](); +}); +}); + `, + errors: [{ messageId: 'updateTestWiring' }], + }, + { + name: 'update test wiring - function call instead of block - not async', + filename: `src/api/v1/ping.spec.ts`, + code: ` +import { beforeAll, describe, it } from '@jest/globals'; +import { amazonSetup } from '@checkdigit/amazon'; +import { createFixture } from '@checkdigit/fixture'; +describe('/ping', () => { + const fixture = createFixture(amazonSetup); + beforeAll(() => fixture.reset()); +}); + `, + output: ` +import { afterAll, beforeAll, describe, it } from '@jest/globals'; +import { amazonSetup } from '@checkdigit/amazon'; +import { createFixture } from '@checkdigit/fixture'; +import createAgent, { type Agent } from '@checkdigit/agent'; +import fixturePlugin from '../../plugin/fixture.test'; +describe('/ping', () => { + const fixture = createFixture(amazonSetup); + let agent: Agent; +beforeAll(async () => { +agent = await createAgent(); +agent.register(await fixturePlugin(fixture)); +agent.enable(); +await fixture.reset(); +}); +afterAll(async () => { +await agent[Symbol.asyncDispose](); +}); +}); + `, + errors: [{ messageId: 'updateTestWiring' }], + }, + { + name: 'update test wiring - deeper folder structure', + filename: `src/api/v1/test/util.spec.ts`, + code: ` +import { beforeAll, describe, it } from '@jest/globals'; +import { amazonSetup } from '@checkdigit/amazon'; +import { createFixture } from '@checkdigit/fixture'; +describe('/ping', () => { + const fixture = createFixture(amazonSetup); + beforeAll(() => fixture.reset()); +}); + `, + output: ` +import { afterAll, beforeAll, describe, it } from '@jest/globals'; +import { amazonSetup } from '@checkdigit/amazon'; +import { createFixture } from '@checkdigit/fixture'; +import createAgent, { type Agent } from '@checkdigit/agent'; +import fixturePlugin from '../../../plugin/fixture.test'; +describe('/ping', () => { + const fixture = createFixture(amazonSetup); + let agent: Agent; +beforeAll(async () => { +agent = await createAgent(); +agent.register(await fixturePlugin(fixture)); +agent.enable(); +await fixture.reset(); +}); +afterAll(async () => { +await agent[Symbol.asyncDispose](); +}); +}); + `, + errors: [{ messageId: 'updateTestWiring' }], + }, + { + name: 'update test wiring - shallower folder structure', + filename: `src/service/pgp.spec.ts`, + code: ` +import { beforeAll, describe, it } from '@jest/globals'; +import { amazonSetup } from '@checkdigit/amazon'; +import { createFixture } from '@checkdigit/fixture'; +describe('/ping', () => { + const fixture = createFixture(amazonSetup); + beforeAll(() => fixture.reset()); +}); + `, + output: ` +import { afterAll, beforeAll, describe, it } from '@jest/globals'; +import { amazonSetup } from '@checkdigit/amazon'; +import { createFixture } from '@checkdigit/fixture'; +import createAgent, { type Agent } from '@checkdigit/agent'; +import fixturePlugin from '../plugin/fixture.test'; +describe('/ping', () => { + const fixture = createFixture(amazonSetup); + let agent: Agent; +beforeAll(async () => { +agent = await createAgent(); +agent.register(await fixturePlugin(fixture)); +agent.enable(); +await fixture.reset(); +}); +afterAll(async () => { +await agent[Symbol.asyncDispose](); +}); +}); + `, + errors: [{ messageId: 'updateTestWiring' }], + }, + { + name: 'only beforeEach is presented', + filename: `src/api/v1/ping.spec.ts`, + code: ` +import { beforeEach, describe, it } from '@jest/globals'; +import { amazonSetup } from '@checkdigit/amazon'; +import { createFixture } from '@checkdigit/fixture'; +describe('/ping', () => { + const fixture = createFixture(amazonSetup); + beforeEach(() => fixture.reset()); +}); + `, + output: ` +import { afterAll, beforeAll, beforeEach, describe, it } from '@jest/globals'; +import { amazonSetup } from '@checkdigit/amazon'; +import { createFixture } from '@checkdigit/fixture'; +import createAgent, { type Agent } from '@checkdigit/agent'; +import fixturePlugin from '../../plugin/fixture.test'; +describe('/ping', () => { + const fixture = createFixture(amazonSetup); + let agent: Agent; +beforeAll(async () => { +agent = await createAgent(); +agent.register(await fixturePlugin(fixture)); +agent.enable(); +}); +beforeEach(() => fixture.reset()); +afterAll(async () => { +await agent[Symbol.asyncDispose](); +}); +}); + `, + errors: [{ messageId: 'updateTestWiring' }], + }, + ], +}); diff --git a/src/agent/agent-test-wiring.ts b/src/agent/agent-test-wiring.ts new file mode 100644 index 0000000..1495ff7 --- /dev/null +++ b/src/agent/agent-test-wiring.ts @@ -0,0 +1,273 @@ +// agent/agent-test-wiring.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { strict as assert } from 'node:assert'; + +import { AST_TOKEN_TYPES, ESLintUtils, TSESTree } from '@typescript-eslint/utils'; +import type { RuleFix, RuleFixer } from '@typescript-eslint/utils/ts-eslint'; +import debug from 'debug'; + +import getDocumentationUrl from '../get-documentation-url'; + +export const ruleId = 'agent-test-wiring'; +const createRule = ESLintUtils.RuleCreator((name) => getDocumentationUrl(name)); +const log = debug('eslint-plugin:agent:agent-test-wiring'); + +const STATEMENT_FIXTURE_RESET = 'fixture.reset()'; +const STATEMENT_FIXTURE_RESET_AWAITED = `await ${STATEMENT_FIXTURE_RESET};`; +const STATEMENT_AGENT_DECLARATION = 'let agent: Agent;'; +const STATEMENT_AGENT_CREATION = 'agent = await createAgent();'; +const STATEMENT_AGENT_REGISTER = 'agent.register(await fixturePlugin(fixture));'; +const STATEMENT_AGENT_ENABLE = 'agent.enable();'; +const STATEMENT_AGENT_DISPOSE = 'await agent[Symbol.asyncDispose]();'; + +const rule: ESLintUtils.RuleModule<'updateTestWiring' | 'unknownError'> = createRule({ + name: ruleId, + meta: { + type: 'suggestion', + docs: { + description: 'Update test wiring.', + }, + messages: { + updateTestWiring: 'Updating test wiring.', + unknownError: 'Unknown error occurred in file "{{fileName}}": {{ error }}.', + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + // eslint-disable-next-line max-lines-per-function + create(context) { + log('Processing file:', context.filename); + const sourceCode = context.sourceCode; + const importDeclarations = new Map(); + let isFixtureUsed = false; + let beforeAll: TSESTree.CallExpression | undefined; + let beforeEach: TSESTree.CallExpression | undefined; + let afterAll: TSESTree.CallExpression | undefined; + + return { + ImportDeclaration(importDeclaration) { + const moduleName = importDeclaration.source.value; + importDeclarations.set(moduleName, importDeclaration); + if ( + moduleName === '@checkdigit/fixture' && + importDeclaration.specifiers.some( + (specifier) => + specifier.type === TSESTree.AST_NODE_TYPES.ImportSpecifier && + specifier.imported.type === TSESTree.AST_NODE_TYPES.Identifier && + specifier.imported.name === 'createFixture', + ) + ) { + isFixtureUsed = true; + } + }, + 'CallExpression[callee.name="beforeAll"]': (callExpression: TSESTree.CallExpression) => { + beforeAll = callExpression; + }, + 'CallExpression[callee.name="beforeEach"]': (callExpression: TSESTree.CallExpression) => { + beforeEach = callExpression; + }, + 'CallExpression[callee.name="afterAll"]': (callExpression: TSESTree.CallExpression) => { + afterAll = callExpression; + }, + // eslint-disable-next-line sonarjs/cognitive-complexity + 'Program:exit'(program) { + if (!isFixtureUsed || (beforeAll === undefined && beforeEach === undefined)) { + // only update test wiring if fixture is used + return; + } + + try { + let jestImportFixer: ((fixer: RuleFixer) => RuleFix) | undefined; + let agentImportFixer: ((fixer: RuleFixer) => RuleFix) | undefined; + let fixturePluginImportFixer: ((fixer: RuleFixer) => RuleFix) | undefined; + let agentDeclarationFixer: ((fixer: RuleFixer) => RuleFix) | undefined; + let beforeAllOrEachFixer: ((fixer: RuleFixer) => RuleFix) | undefined; + let afterAllFixer: ((fixer: RuleFixer) => RuleFix) | undefined; + + const lastImportDeclaration = [...importDeclarations.values()].at(-1); + assert.ok(lastImportDeclaration); + + // make sure that afterAll is imported from jest + const jestImportDeclaration = importDeclarations.get('@jest/globals'); + assert.ok(jestImportDeclaration); + const importsToAdd = ['afterAll', 'beforeAll'].filter( + (jestHook) => + !jestImportDeclaration.specifiers.some( + (specifier) => + specifier.type === TSESTree.AST_NODE_TYPES.ImportSpecifier && + specifier.imported.type === TSESTree.AST_NODE_TYPES.Identifier && + specifier.imported.name === jestHook, + ), + ); + if (importsToAdd.length > 0) { + const firstImportSpecifier = jestImportDeclaration.specifiers[0]; + assert.ok(firstImportSpecifier); + jestImportFixer = (fixer: RuleFixer) => + fixer.insertTextBefore(firstImportSpecifier, `${importsToAdd.join(', ')}, `); + } + + // make sure that agent is imported + const agentImportDeclaration = importDeclarations.get('@checkdigit/agent'); + if (!agentImportDeclaration) { + agentImportFixer = (fixer: RuleFixer) => + fixer.insertTextAfter( + lastImportDeclaration, + `\nimport createAgent, { type Agent } from '@checkdigit/agent';`, + ); + } + + // make sure that fixture plugin is imported + const pathLets = context.filename.split('/'); + const currentFileIndex = pathLets.length - 1; + const pluginFolderIndex = pathLets.lastIndexOf('src') + 1; + // it should be safe to assume that the test code is always at least one level deeper than the plugin folder + const fixturePluginImportPath = `${'../'.repeat(currentFileIndex - pluginFolderIndex)}plugin/fixture.test`; + if (!importDeclarations.get(fixturePluginImportPath)) { + fixturePluginImportFixer = (fixer: RuleFixer) => + fixer.insertTextAfter(lastImportDeclaration, `\nimport fixturePlugin from '${fixturePluginImportPath}';`); + } + + // inject agent declaration and initialization + if (beforeAll === undefined) { + // create `beforeAll` block if it doesn't exist + beforeAllOrEachFixer = (fixer: RuleFixer) => + fixer.insertTextBefore( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + beforeEach!, + [ + STATEMENT_AGENT_DECLARATION, + `beforeAll(async () => {`, + [STATEMENT_AGENT_CREATION, STATEMENT_AGENT_REGISTER, STATEMENT_AGENT_ENABLE].join('\n'), + `});\n`, + ].join('\n'), + ); + } else { + const beforeAllArgument = beforeAll.arguments[0]; + assert.ok(beforeAllArgument !== undefined); + if (!sourceCode.getText(beforeAllArgument).includes(STATEMENT_AGENT_CREATION)) { + if ( + beforeAllArgument.type === TSESTree.AST_NODE_TYPES.ArrowFunctionExpression && + beforeAllArgument.body.type === TSESTree.AST_NODE_TYPES.BlockStatement + ) { + const fixtureResetStatement = beforeAllArgument.body.body.find( + (statement) => sourceCode.getText(statement) === STATEMENT_FIXTURE_RESET_AWAITED, + ); + assert.ok(fixtureResetStatement !== undefined); + beforeAllOrEachFixer = (fixer: RuleFixer) => + fixer.replaceText( + fixtureResetStatement, + [ + STATEMENT_AGENT_CREATION, + STATEMENT_AGENT_REGISTER, + STATEMENT_AGENT_ENABLE, + STATEMENT_FIXTURE_RESET_AWAITED, + ].join('\n'), + ); + } else { + beforeAllOrEachFixer = (fixer: RuleFixer) => + fixer.replaceText( + beforeAllArgument, + [ + `async () => {`, + STATEMENT_AGENT_CREATION, + STATEMENT_AGENT_REGISTER, + STATEMENT_AGENT_ENABLE, + STATEMENT_FIXTURE_RESET_AWAITED, + `}`, + ].join('\n'), + ); + } + agentDeclarationFixer = (fixer: RuleFixer) => + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + fixer.insertTextBefore(beforeAll!, `${STATEMENT_AGENT_DECLARATION}\n`); + } + } + + // inject agent disposal to `afterAll` block + if (afterAll !== undefined) { + const afterAllArrowFunctionExpression = afterAll.arguments[0]; + assert.ok( + afterAllArrowFunctionExpression !== undefined && + afterAllArrowFunctionExpression.type === TSESTree.AST_NODE_TYPES.ArrowFunctionExpression, + ); + const arrowFunctionBody = afterAllArrowFunctionExpression.body; + assert.ok(arrowFunctionBody.type === TSESTree.AST_NODE_TYPES.BlockStatement); + + const afterAllBodyText = sourceCode.getText(arrowFunctionBody); + if (!afterAllBodyText.includes(STATEMENT_AGENT_DISPOSE)) { + const lastStatement = arrowFunctionBody.body.at(-1); + assert.ok(lastStatement); + afterAllFixer = (fixer: RuleFixer) => fixer.insertTextAfter(lastStatement, STATEMENT_AGENT_DISPOSE); + } + } else { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const nextToken = sourceCode.getTokenAfter(beforeAll ?? beforeEach!); + afterAllFixer = (fixer: RuleFixer) => + fixer.insertTextAfter( + nextToken !== null && nextToken.type === AST_TOKEN_TYPES.Punctuator + ? nextToken + : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + beforeAll!, + ['', `afterAll(async () => {`, STATEMENT_AGENT_DISPOSE, `});`].join('\n'), + ); + } + + if ( + jestImportFixer !== undefined || + agentImportFixer !== undefined || + fixturePluginImportFixer !== undefined || + agentDeclarationFixer !== undefined || + beforeAllOrEachFixer !== undefined || + afterAllFixer !== undefined + ) { + context.report({ + messageId: 'updateTestWiring', + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + node: beforeAll ?? beforeEach!, + *fix(fixer) { + if (jestImportFixer !== undefined) { + yield jestImportFixer(fixer); + } + if (agentImportFixer !== undefined) { + yield agentImportFixer(fixer); + } + if (fixturePluginImportFixer !== undefined) { + yield fixturePluginImportFixer(fixer); + } + if (agentDeclarationFixer !== undefined) { + yield agentDeclarationFixer(fixer); + } + if (beforeAllOrEachFixer !== undefined) { + yield beforeAllOrEachFixer(fixer); + } + if (afterAllFixer !== undefined) { + yield afterAllFixer(fixer); + } + }, + }); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to apply ${ruleId} rule for file "${context.filename}":`, error); + context.report({ + node: program, + messageId: 'unknownError', + data: { + fileName: context.filename, + error: error instanceof Error ? error.toString() : JSON.stringify(error), + }, + }); + } + }, + }; + }, +}); + +export default rule; diff --git a/src/agent/fetch-response-body-json.spec.ts b/src/agent/fetch-response-body-json.spec.ts new file mode 100644 index 0000000..e2397ec --- /dev/null +++ b/src/agent/fetch-response-body-json.spec.ts @@ -0,0 +1,265 @@ +// agent/fetch-response-body-json.spec.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import createTester from '../ts-tester.test'; +import rule, { ruleId } from './fetch-response-body-json'; + +createTester().run(ruleId, rule, { + valid: [ + { + name: 'no change if no "json" property is found in the response type', + code: ` + const response = {body: 'foo'}; + const body = response.body; + `, + }, + ], + invalid: [ + { + name: 'first body access is inside variable declaration', + code: `() => { + const response = await fetch(url); + const body = response.body; + }`, + output: `() => { + const response = await fetch(url); + const body = await response.json(); + }`, + errors: [{ messageId: 'replaceBodyWithJson' }], + }, + { + name: 'first body access along with nested property access is inside variable declaration', + code: `() => { + const response = await fetch(url); + const data = response.body.data; + }`, + output: `() => { + const response = await fetch(url); +const responseBody = await response.json(); + const data = responseBody.data; + }`, + errors: [{ messageId: 'replaceBodyWithJson' }, { messageId: 'replaceBodyWithJson' }], + }, + { + name: 'first body access is not inside of variable declaration', + code: `() => { + const response = await fetch(url); + assert(response.body); + }`, + output: `() => { + const response = await fetch(url); +const responseBody = await response.json(); + assert(responseBody); + }`, + errors: [{ messageId: 'replaceBodyWithJson' }, { messageId: 'replaceBodyWithJson' }], + }, + { + name: 'first body access along with nested property access is not inside of variable declaration', + code: `() => { + const response = await fetch(url); + assert(response.body.data); + }`, + output: `() => { + const response = await fetch(url); +const responseBody = await response.json(); + assert(responseBody.data); + }`, + errors: [{ messageId: 'replaceBodyWithJson' }, { messageId: 'replaceBodyWithJson' }], + }, + { + name: 'body access is inside of return statement', + code: ` + async function foo() { + const response = await fetch(url); + return response.body; + } + `, + output: ` + async function foo() { + const response = await fetch(url); +const responseBody = await response.json(); + return responseBody; + } + `, + errors: [{ messageId: 'replaceBodyWithJson' }, { messageId: 'replaceBodyWithJson' }], + }, + { + name: 'body access along with nested property access is inside of return statement', + code: `() => { + const response = await fetch(url); + return response.body.data; + }`, + output: `() => { + const response = await fetch(url); +const responseBody = await response.json(); + return responseBody.data; + }`, + errors: [{ messageId: 'replaceBodyWithJson' }, { messageId: 'replaceBodyWithJson' }], + }, + { + name: 'multiple body access in the same function', + code: `() => { + const response = await fetch(url); + assert(response.body); + assert(response.body.data); + }`, + output: `() => { + const response = await fetch(url); +const responseBody = await response.json(); + assert(responseBody); + assert(responseBody.data); + }`, + errors: [ + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + ], + }, + { + name: 'body access againt multiple responses in the same function', + code: `() => { + const response = await fetch(url); + assert(response.body); + const response2 = await fetch(url2); + assert(response2.body); + }`, + output: `() => { + const response = await fetch(url); +const responseBody = await response.json(); + assert(responseBody); + const response2 = await fetch(url2); +const response2Body = await response2.json(); + assert(response2Body); + }`, + errors: [ + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + ], + }, + { + name: 'multiple body accesses againt multiple responses in the same function', + code: `() => { + const response = await fetch(url); + assert(response.body); + assert(response.body.data); + const response2 = await fetch(url2); + assert(response2.body); + assert(response2.body.data); + }`, + output: `() => { + const response = await fetch(url); +const responseBody = await response.json(); + assert(responseBody); + assert(responseBody.data); + const response2 = await fetch(url2); +const response2Body = await response2.json(); + assert(response2Body); + assert(response2Body.data); + }`, + errors: [ + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + ], + }, + { + name: 'multiple body accesses againt multiple responses in the same function with mixed ordering', + code: `() => { + const response = await fetch(url); + const response2 = await fetch(url2); + assert(response2.body.data); + assert(response2.body); + assert(response.body); + assert(response.body.data); + }`, + output: `() => { + const response = await fetch(url); + const response2 = await fetch(url2); +const response2Body = await response2.json(); + assert(response2Body.data); + assert(response2Body); +const responseBody = await response.json(); + assert(responseBody); + assert(responseBody.data); + }`, + errors: [ + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + ], + }, + { + name: 'multiple body accesses againt multiple responses in multiple functions with mixed ordering', + code: `() => { + const response = await fetch(url); + const response2 = await fetch(url2); + assert(response2.body.data); + assert(response2.body); + assert(response.body); + assert(response.body.data); + } + () => { + const response = await fetch(url3); + return response.body; + }`, + output: `() => { + const response = await fetch(url); + const response2 = await fetch(url2); +const response2Body = await response2.json(); + assert(response2Body.data); + assert(response2Body); +const responseBody = await response.json(); + assert(responseBody); + assert(responseBody.data); + } + () => { + const response = await fetch(url3); +const responseBody = await response.json(); + return responseBody; + }`, + errors: [ + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + { messageId: 'replaceBodyWithJson' }, + ], + }, + { + name: 'work with expression like body.forEach', + code: `() => { + const response = await fetch(url); + response.body.forEach(() => {}); + }`, + output: `() => { + const response = await fetch(url); +const responseBody = await response.json(); + responseBody.forEach(() => {}); + }`, + errors: [{ messageId: 'replaceBodyWithJson' }, { messageId: 'replaceBodyWithJson' }], + }, + { + name: 'report error for inline fetch call', + code: `() => { + (await fetch(url)).body; + }`, + errors: [{ messageId: 'refactorNeeded' }], + }, + ], +}); diff --git a/src/agent/fetch-response-body-json.ts b/src/agent/fetch-response-body-json.ts new file mode 100644 index 0000000..3899756 --- /dev/null +++ b/src/agent/fetch-response-body-json.ts @@ -0,0 +1,194 @@ +// agent/fetch-response-body-json.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { strict as assert } from 'node:assert'; + +import { AST_NODE_TYPES, ESLintUtils, TSESTree } from '@typescript-eslint/utils'; + +import getDocumentationUrl from '../get-documentation-url'; +import { getAncestor } from '../library/ts-tree'; +import { isFetchResponse } from './fetch'; + +export const ruleId = 'fetch-response-body-json'; + +const createRule = ESLintUtils.RuleCreator((name) => getDocumentationUrl(name)); + +interface Change { + enclosingFunction: TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression | TSESTree.FunctionDeclaration; + enclosingStatement: TSESTree.VariableDeclaration | TSESTree.ExpressionStatement | TSESTree.ReturnStatement; + enclosingStatementIndex: number; + responseBodyNode: TSESTree.MemberExpression; + responseVariableName: string; + responseBodyVariableName: string; + isResponseBodyVariableDeclared: boolean; + // replacementText: string; +} + +const rule: ESLintUtils.RuleModule<'unknownError' | 'replaceBodyWithJson' | 'refactorNeeded'> = createRule({ + name: ruleId, + meta: { + type: 'suggestion', + docs: { + description: 'Replace "response.body" with "await response.json()".', + }, + messages: { + refactorNeeded: + 'Please extract the fetch call and check its response status code before accessing its response body.', + replaceBodyWithJson: 'Replace "response.body" with "await response.json()".', + unknownError: 'Unknown error occurred in file "{{fileName}}": {{ error }}.', + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + const parserServices = ESLintUtils.getParserServices(context); + const typeChecker = parserServices.program.getTypeChecker(); + const allChanges = new Map>(); + + return { + 'MemberExpression[property.name="body"]': (responseBodyNode: TSESTree.MemberExpression) => { + try { + const responseNode = parserServices.esTreeNodeToTSNodeMap.get(responseBodyNode.object); + const responseType = typeChecker.getTypeAtLocation(responseNode); + + if (isFetchResponse(responseType)) { + if (responseBodyNode.object.type !== AST_NODE_TYPES.Identifier) { + context.report({ + node: responseBodyNode, + messageId: 'refactorNeeded', + }); + return; + } + + const enclosingFunction = getAncestor( + responseBodyNode, + (node: TSESTree.Node) => + node.type === AST_NODE_TYPES.ArrowFunctionExpression || + node.type === AST_NODE_TYPES.FunctionExpression || + node.type === AST_NODE_TYPES.FunctionDeclaration, + ) as TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression | TSESTree.FunctionDeclaration; + const enclosingStatement = getAncestor( + responseBodyNode, + (node: TSESTree.Node) => + (node.type === AST_NODE_TYPES.VariableDeclaration || + node.type === AST_NODE_TYPES.ExpressionStatement || + node.type === AST_NODE_TYPES.ReturnStatement) && + node.parent.type === AST_NODE_TYPES.BlockStatement, + ) as TSESTree.VariableDeclaration | TSESTree.ExpressionStatement | TSESTree.ReturnStatement; + const enclosingStatementIndex = (enclosingFunction.body as TSESTree.BlockStatement).body.indexOf( + enclosingStatement, + ); + const responseVariableName = responseBodyNode.object.name; + const isResponseBodyVariableDeclared = + enclosingStatement.type === AST_NODE_TYPES.VariableDeclaration && + enclosingStatement.declarations.some( + (declaration) => + declaration.init === responseBodyNode || + (declaration.init?.type === AST_NODE_TYPES.TSAsExpression && + declaration.init.expression === responseBodyNode), + ); + const responseBodyVariableName = isResponseBodyVariableDeclared + ? (enclosingStatement.declarations.find( + (declaration) => + declaration.init === responseBodyNode || + (declaration.init?.type === AST_NODE_TYPES.TSAsExpression && + declaration.init.expression === responseBodyNode), + )?.id as unknown as string) + : `${responseBodyNode.object.name}Body`; + + const change: Change = { + enclosingFunction, + enclosingStatement, + enclosingStatementIndex, + responseVariableName, + responseBodyNode, + responseBodyVariableName, + isResponseBodyVariableDeclared, + }; + + const changesByFunction = allChanges.get(enclosingFunction) ?? new Map(); + const changesByResponse = changesByFunction.get(responseVariableName) ?? []; + changesByResponse.push(change); + changesByFunction.set(responseVariableName, changesByResponse); + allChanges.set(enclosingFunction, changesByFunction); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to apply ${ruleId} rule for file "${context.filename}":`, error); + context.report({ + node: responseBodyNode, + messageId: 'unknownError', + data: { + fileName: context.filename, + error: error instanceof Error ? error.toString() : JSON.stringify(error), + }, + }); + } + }, + + 'Program:exit': () => { + if (allChanges.size === 0) { + return; + } + + const fixes: { node: TSESTree.Node | TSESTree.Token; text: string; insert: boolean }[] = []; + for (const changesByFunction of allChanges.values()) { + for (const changesByResponse of changesByFunction.values()) { + const orderedChanges = changesByResponse.sort( + (changeA, changeB) => changeA.enclosingStatementIndex - changeB.enclosingStatementIndex, + ); + const firstChange = orderedChanges[0]; + assert(firstChange); + + const { + responseBodyNode, + responseVariableName, + responseBodyVariableName, + isResponseBodyVariableDeclared, + enclosingStatement, + } = firstChange; + + let remainingChanges; + if (!isResponseBodyVariableDeclared) { + fixes.push({ + node: context.sourceCode.getTokenBefore(enclosingStatement) as TSESTree.Token, + text: `\nconst ${responseBodyVariableName} = await ${responseVariableName}.json();`, + insert: true, + }); + remainingChanges = orderedChanges; + } else { + fixes.push({ + node: responseBodyNode, + text: `await ${responseVariableName}.json()`, + insert: false, + }); + remainingChanges = orderedChanges.slice(1); + } + + for (const change of remainingChanges) { + fixes.push({ node: change.responseBodyNode, text: responseBodyVariableName, insert: false }); + } + } + } + + for (const fix of fixes.reverse()) { + context.report({ + node: fix.node, + messageId: 'replaceBodyWithJson', + fix(fixer) { + return fix.insert ? fixer.insertTextAfter(fix.node, fix.text) : fixer.replaceText(fix.node, fix.text); + }, + }); + } + }, + }; + }, +}); + +export default rule; diff --git a/src/agent/fetch-response-header-getter.spec.ts b/src/agent/fetch-response-header-getter.spec.ts new file mode 100644 index 0000000..eded3f9 --- /dev/null +++ b/src/agent/fetch-response-header-getter.spec.ts @@ -0,0 +1,180 @@ +// agent/fetch-response-header-getter-ts.spec.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import createTester from '../ts-tester.test'; +import rule, { ruleId } from './fetch-response-header-getter'; + +createTester().run(ruleId, rule, { + valid: [ + { + name: 'no change for fixture.api.get()', + code: ` + fixture.api.get('/ping'); + `, + }, + { + name: 'no change for non-response object', + code: ` + const map = new Map(); + map.get('key'); + + const headers = new Headers(); + headers.get('etag'); + `, + }, + { + name: 'no change for request.get()', + code: ` + const request : { headers: Headers } = await getRequest(); + request.get(ETAG); + `, + }, + { + name: 'no change of response.get() if the type of response does not include "headers" property', + code: ` + const response : Record = await getResponse(); + response.get(ETAG); + `, + }, + { + name: 'no change of request.get() if the variable name is "request"', + code: ` + type Context = { get: (string)=>string, headers: Record }; + async function doSomething(request: Context) { + const etagRequestHeader = request.get(ETAG); + } + `, + }, + { + name: 'no change of request.get() if the type the request is InboundContext', + code: ` + async function doSomething(req: InboundContext) { + const etagRequestHeader = req.get(ETAG); + } + `, + }, + { + name: 'no change of request.get() if the type the request is xxxRequestType', + code: ` + async function doSomething(fooReq: FooRequestType) { + const etagRequestHeader = fooReq.get(ETAG); + } + `, + }, + { + name: 'no change if get() method is already used - with non-typed fetch', + code: ` + const response = await fetch(\`https://example.org\`); + assert.equal(response.headers.get('etag'), '1'); + assert.equal(response.headers.get(ETAG), '1'); + `, + }, + ], + invalid: [ + { + name: 'use get() method to get header value from the headers object if the typing allows.', + code: `async function doSomething() { + const ETAG = 'etag'; + const response = await fetch('https://openapi-cli.checkdigit/sample/v1/ping'); + assert.equal(response.headers[ETAG], '123'); + }`, + output: `async function doSomething() { + const ETAG = 'etag'; + const response = await fetch('https://openapi-cli.checkdigit/sample/v1/ping'); + assert.equal(response.headers.get(ETAG), '123'); + }`, + errors: [{ messageId: 'useGetter' }], + }, + { + name: 'access using string literal', + code: ` + const response = await fetch('https://openapi-cli.checkdigit/sample/v1/ping'); + response.headers['created-on']; + `, + output: ` + const response = await fetch('https://openapi-cli.checkdigit/sample/v1/ping'); + response.headers.get('created-on'); + `, + errors: [{ messageId: 'useGetter' }], + }, + { + name: 'access using Template literal', + code: ` + const response = await fetch('https://openapi-cli.checkdigit/sample/v1/ping'); + response.headers[\`etag\`]; + `, + output: ` + const response = await fetch('https://openapi-cli.checkdigit/sample/v1/ping'); + response.headers.get(\`etag\`); + `, + errors: [{ messageId: 'useGetter' }], + }, + { + name: 'replace the direct headers property access with getter', + code: ` + const response = await fetch('https://openapi-cli.checkdigit/sample/v1/ping'); + assert.equal(response.headers.etag, '1'); + `, + output: ` + const response = await fetch('https://openapi-cli.checkdigit/sample/v1/ping'); + assert.equal(response.headers.get('etag'), '1'); + `, + errors: [{ messageId: 'useGetter' }], + }, + { + name: 'still work with status assertion', + code: ` + import { strict as assert } from 'node:assert'; + import { StatusCodes } from 'http-status-codes'; + + const response = await fetch('https://openapi-cli.checkdigit/sample/v1/ping'); + assert.equal(response.status, StatusCodes.OK); + assert.equal(response.headers['updated-on'], '1'); + assert.equal(response.headers.etag, '1'); + `, + output: ` + import { strict as assert } from 'node:assert'; + import { StatusCodes } from 'http-status-codes'; + + const response = await fetch('https://openapi-cli.checkdigit/sample/v1/ping'); + assert.equal(response.status, StatusCodes.OK); + assert.equal(response.headers.get('updated-on'), '1'); + assert.equal(response.headers.get('etag'), '1'); + `, + errors: [{ messageId: 'useGetter' }, { messageId: 'useGetter' }], + }, + { + name: 'work with non-typed fetch', + code: ` + const response = await fetch(\`https://example.org\`); + assert.equal(response.headers.etag, '1'); + assert.equal(response.headers['etag'], '1'); + assert.equal(response.headers[ETAG], '1'); + `, + output: ` + const response = await fetch(\`https://example.org\`); + assert.equal(response.headers.get('etag'), '1'); + assert.equal(response.headers.get('etag'), '1'); + assert.equal(response.headers.get(ETAG), '1'); + `, + errors: [{ messageId: 'useGetter' }, { messageId: 'useGetter' }, { messageId: 'useGetter' }], + }, + { + name: 'response.get() should be changed to response.headers.get()', + code: ` + const response : { headers: Headers } = await getResponse(); + response.get(ETAG); + `, + output: ` + const response : { headers: Headers } = await getResponse(); + response.headers.get(ETAG); + `, + errors: [{ messageId: 'useGetter' }], + }, + ], +}); diff --git a/src/agent/fetch-response-header-getter.ts b/src/agent/fetch-response-header-getter.ts new file mode 100644 index 0000000..cb9bc13 --- /dev/null +++ b/src/agent/fetch-response-header-getter.ts @@ -0,0 +1,148 @@ +// agent/fetch-response-header-getter-ts.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { AST_NODE_TYPES, ESLintUtils, TSESTree } from '@typescript-eslint/utils'; + +import getDocumentationUrl from '../get-documentation-url'; + +export const ruleId = 'fetch-response-header-getter-ts'; +const HEADER_BUILTIN_FUNCTIONS = Object.keys(Headers.prototype); + +const createRule = ESLintUtils.RuleCreator((name) => getDocumentationUrl(name)); + +const rule: ESLintUtils.RuleModule<'unknownError' | 'useGetter'> = createRule({ + name: ruleId, + meta: { + type: 'suggestion', + docs: { + description: 'Use "get()" method to get header value from the headers object of the fetch response.', + }, + messages: { + useGetter: 'Use "get()" method to get header value from the headers object of the fetch response.', + unknownError: 'Unknown error occurred in file "{{fileName}}": {{ error }}.', + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + const parserServices = ESLintUtils.getParserServices(context); + const typeChecker = parserServices.program.getTypeChecker(); + const sourceCode = context.sourceCode; + + return { + MemberExpression: (responseHeadersAccess: TSESTree.MemberExpression) => { + try { + if ( + responseHeadersAccess.property.type === AST_NODE_TYPES.Identifier && + HEADER_BUILTIN_FUNCTIONS.includes(responseHeadersAccess.property.name) + ) { + // skip Headers's built-in function calls + return; + } + + const responseHeadersTsNode = parserServices.esTreeNodeToTSNodeMap.get(responseHeadersAccess.object); + let responseHeadersType = typeChecker.getTypeAtLocation(responseHeadersTsNode); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + responseHeadersType = responseHeadersType.isUnion() ? responseHeadersType.types[0]! : responseHeadersType; + const responseHeadersTypeName = // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (responseHeadersType.symbol ?? responseHeadersType.aliasSymbol)?.escapedName; + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + if (responseHeadersTypeName !== 'Headers' && responseHeadersTypeName !== 'HeaderGetter') { + return; + } + + let replacementText: string; + if (!responseHeadersAccess.computed) { + // e.g. headers.etag + replacementText = `${sourceCode.getText(responseHeadersAccess.object)}.get('${sourceCode.getText(responseHeadersAccess.property)}')`; + } else if ( + responseHeadersAccess.property.type === AST_NODE_TYPES.Identifier || + responseHeadersAccess.property.type === AST_NODE_TYPES.Literal || + responseHeadersAccess.property.type === AST_NODE_TYPES.TemplateLiteral + ) { + replacementText = `${sourceCode.getText(responseHeadersAccess.object)}.get(${sourceCode.getText(responseHeadersAccess.property)})`; + } else { + throw new Error(`Unexpected property type: ${responseHeadersAccess.property.type}`); + } + + context.report({ + messageId: 'useGetter', + node: responseHeadersAccess.property, + fix(fixer) { + return fixer.replaceText(responseHeadersAccess, replacementText); + }, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to apply ${ruleId} rule for file "${context.filename}":`, error); + context.report({ + node: responseHeadersAccess, + messageId: 'unknownError', + data: { + fileName: context.filename, + error: error instanceof Error ? error.toString() : JSON.stringify(error), + }, + }); + } + }, + + // convert response.get() to response.headers.get() + 'CallExpression[callee.property.name="get"]': (responseHeadersAccess: TSESTree.CallExpression) => { + try { + if (responseHeadersAccess.callee.type !== AST_NODE_TYPES.MemberExpression) { + return; + } + + // skip request-like calls + if ( + responseHeadersAccess.callee.object.type !== AST_NODE_TYPES.Identifier || + responseHeadersAccess.callee.object.name === 'request' + ) { + return; + } + const responseNode = responseHeadersAccess.callee.object; + const responseHeadersTsNode = parserServices.esTreeNodeToTSNodeMap.get(responseNode); + const responseType = typeChecker.getTypeAtLocation(responseHeadersTsNode); + const typeName = typeChecker.typeToString(responseType); + if (typeName === 'InboundContext' || typeName.endsWith('RequestType')) { + return; + } + + // make sure the response type has "headers" property + const hasHeadersProperty = responseType.getProperties().some((symbol) => symbol.name === 'headers'); + if (!hasHeadersProperty) { + return; + } + + const replacementText = `${sourceCode.getText(responseNode)}.headers`; + context.report({ + messageId: 'useGetter', + node: responseHeadersAccess, + fix(fixer) { + return fixer.replaceText(responseNode, replacementText); + }, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to apply ${ruleId} rule for file "${context.filename}":`, error); + context.report({ + node: responseHeadersAccess, + messageId: 'unknownError', + data: { + fileName: context.filename, + error: error instanceof Error ? error.toString() : JSON.stringify(error), + }, + }); + } + }, + }; + }, +}); + +export default rule; diff --git a/src/agent/fetch-response-status.spec.ts b/src/agent/fetch-response-status.spec.ts new file mode 100644 index 0000000..9ec9882 --- /dev/null +++ b/src/agent/fetch-response-status.spec.ts @@ -0,0 +1,73 @@ +// agent/fetch-response-status.spec.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import createTester from '../ts-tester.test'; +import rule, { ruleId } from './fetch-response-status'; + +createTester().run(ruleId, rule, { + valid: [ + { + name: 'destructuring status code is fine', + code: `async function foo() { + const { status } = await fetch(url); + assert.equal(status, StatusCode.Ok); + }`, + }, + { + name: 'destructuring status code is fine - with renaming', + code: `async function foo() { + const { status: statusCode } = await fetch(url); + assert.equal(status, StatusCode.Ok); + }`, + }, + ], + invalid: [ + { + name: 'change statusCode to status - shorthand (no renaming)', + code: `async function foo() { + const { statusCode } = await fetch(url); + assert.equal(statusCode, StatusCode.Ok); + }`, + output: `async function foo() { + const { status: statusCode } = await fetch(url); + assert.equal(statusCode, StatusCode.Ok); + }`, + errors: [{ messageId: 'renameStatusCodeProperty' }], + }, + { + name: 'change statusCode to status - leave renamed identifier along', + code: `async function foo() { + const { statusCode: someStatusCode } = await fetch(url); + assert.equal(someStatusCode, StatusCode.Ok); + }`, + output: `async function foo() { + const { status: someStatusCode } = await fetch(url); + assert.equal(someStatusCode, StatusCode.Ok); + }`, + errors: [{ messageId: 'renameStatusCodeProperty' }], + }, + { + name: 'not directly destructuring fetch', + code: `function ping() { + return fetch(url); + } + async function foo() { + const { statusCode } = await ping(); + assert.equal(statusCode, StatusCode.Ok); + }`, + output: `function ping() { + return fetch(url); + } + async function foo() { + const { status: statusCode } = await ping(); + assert.equal(statusCode, StatusCode.Ok); + }`, + errors: [{ messageId: 'renameStatusCodeProperty' }], + }, + ], +}); diff --git a/src/agent/fetch-response-status.ts b/src/agent/fetch-response-status.ts new file mode 100644 index 0000000..c225fed --- /dev/null +++ b/src/agent/fetch-response-status.ts @@ -0,0 +1,100 @@ +// agent/fetch-response-status.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { AST_NODE_TYPES, ESLintUtils, TSESTree } from '@typescript-eslint/utils'; + +import getDocumentationUrl from '../get-documentation-url'; +import { isFetchResponse } from './fetch'; + +export const ruleId = 'fetch-response-status'; + +const createRule = ESLintUtils.RuleCreator((name) => getDocumentationUrl(name)); + +const rule: ESLintUtils.RuleModule<'unknownError' | 'renameStatusCodeProperty'> = createRule({ + name: ruleId, + meta: { + type: 'problem', + docs: { + description: 'Replace "response.body" with "await response.json()".', + }, + messages: { + renameStatusCodeProperty: 'Rename "statusCode" with "status".', + unknownError: 'Unknown error occurred in file "{{fileName}}": {{ error }}.', + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + const parserServices = ESLintUtils.getParserServices(context); + const typeChecker = parserServices.program.getTypeChecker(); + + return { + VariableDeclaration: (variableDeclaration: TSESTree.VariableDeclaration) => { + const variableInit = variableDeclaration.declarations[0]?.init; + if ( + !variableInit || + variableInit.type !== AST_NODE_TYPES.AwaitExpression || + variableInit.argument.type !== AST_NODE_TYPES.CallExpression + ) { + return; + } + + const variableId = variableDeclaration.declarations[0]?.id; + if (variableId.type !== AST_NODE_TYPES.ObjectPattern) { + return; + } + const statusCodeProperty = variableId.properties.find( + (property): property is TSESTree.Property => + property.type === AST_NODE_TYPES.Property && + property.key.type === AST_NODE_TYPES.Identifier && + property.key.name === 'statusCode', + ); + if (!statusCodeProperty) { + return; + } + + if ( + variableInit.argument.callee.type !== AST_NODE_TYPES.Identifier || + variableInit.argument.callee.name !== 'fetch' + ) { + const variableNode = parserServices.esTreeNodeToTSNodeMap.get(variableId); + const variableType = typeChecker.getTypeAtLocation(variableNode); + if (!isFetchResponse(variableType)) { + return; + } + } + + try { + context.report({ + node: statusCodeProperty, + messageId: 'renameStatusCodeProperty', + fix(fixer) { + return statusCodeProperty.shorthand + ? fixer.replaceText(statusCodeProperty, 'status: statusCode') + : fixer.replaceText(statusCodeProperty.key, 'status'); + }, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to apply ${ruleId} rule for file "${context.filename}":`, error); + context.report({ + node: statusCodeProperty, + messageId: 'unknownError', + data: { + fileName: context.filename, + error: error instanceof Error ? error.toString() : JSON.stringify(error), + }, + }); + } + }, + }; + }, +}); + +export default rule; diff --git a/src/agent/fetch-then.spec.ts b/src/agent/fetch-then.spec.ts new file mode 100644 index 0000000..d3d4e7d --- /dev/null +++ b/src/agent/fetch-then.spec.ts @@ -0,0 +1,147 @@ +// agent/fetch-then.spec.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import createTester from '../ts-tester.test'; +import rule, { ruleId } from './fetch-then'; + +createTester().run(ruleId, rule, { + valid: [ + { + name: 'skip regular fixture calls which will be handled in "no-fixture" rule', + code: ` + const pingResponse = await fixture.api.get(\`/sample-service/v1/ping\`).expect(StatusCodes.OK); + const body = pingResponse.body; + const timeDifference = Date.now() - new Date(body.serverTime).getTime(); + assert.ok(timeDifference >= 0 && timeDifference < 200); + `, + }, + ], + invalid: [ + { + name: 'with assertions', + code: ` + const responses = await Promise.all([ + fixture.api.put(\`\${BASE_PATH}/key\`).send(keyData).expect(StatusCodes.NO_CONTENT), + fixture.api.put(\`\${BASE_PATH}/key\`).send(keyData).expect(StatusCodes.NO_CONTENT), + ]); + `, + output: ` + const responses = await Promise.all([ + // eslint-disable-next-line @checkdigit/no-promise-instance-method + fetch(\`\${BASE_PATH}/key\`, { + method: 'PUT', + body: JSON.stringify(keyData), + }).then((res) => { + assert.equal(res.status, StatusCodes.NO_CONTENT); + return res; + }), + // eslint-disable-next-line @checkdigit/no-promise-instance-method + fetch(\`\${BASE_PATH}/key\`, { + method: 'PUT', + body: JSON.stringify(keyData), + }).then((res) => { + assert.equal(res.status, StatusCodes.NO_CONTENT); + return res; + }), + ]); + `, + errors: [{ messageId: 'preferNativeFetch' }, { messageId: 'preferNativeFetch' }], + }, + // { + // name: 'adjust header access correctly', + // code: ` + // const responses = await Promise.all([ + // fixture.api.put(\`\${BASE_PATH}/key\`).send(keyData).expect(StatusCodes.NO_CONTENT), + // fixture.api.put(\`\${BASE_PATH}/key\`).send(keyData).expect(StatusCodes.NO_CONTENT), + // ]); + // assert.deepEqual(responses.map((response) => response.headers.etag).sort(), ['1', '1']); + // assert.equal(responses[0].headers[LAST_MODIFIED_HEADER], responses[1].headers[LAST_MODIFIED_HEADER]); + // assert.equal(responses[0].get(CREATED_ON_HEADER), responses[1].get(CREATED_ON_HEADER)); + // assert.equal(responses[0].headers.get(UPDATED_ON_HEADER), responses[1].headers.get(UPDATED_ON_HEADER)); + // `, + // output: ` + // const responses = await Promise.all([ + // // eslint-disable-next-line @checkdigit/no-promise-instance-method + // fetch(\`\${BASE_PATH}/key\`, { + // method: 'PUT', + // body: JSON.stringify(keyData), + // }).then((res) => { + // assert.equal(res.status, StatusCodes.NO_CONTENT); + // return res; + // }), + // // eslint-disable-next-line @checkdigit/no-promise-instance-method + // fetch(\`\${BASE_PATH}/key\`, { + // method: 'PUT', + // body: JSON.stringify(keyData), + // }).then((res) => { + // assert.equal(res.status, StatusCodes.NO_CONTENT); + // return res; + // }), + // ]); + // assert.deepEqual(responses.map((response) => response.headers.get('etag')).sort(), ['1', '1']); + // assert.equal(responses[0].headers.get(LAST_MODIFIED_HEADER), responses[1].headers.get(LAST_MODIFIED_HEADER)); + // assert.equal(responses[0].headers.get(CREATED_ON_HEADER), responses[1].headers.get(CREATED_ON_HEADER)); + // assert.equal(responses[0].headers.get(UPDATED_ON_HEADER), responses[1].headers.get(UPDATED_ON_HEADER)); + // `, + // errors: [ + // { messageId: 'preferNativeFetch' }, + // { messageId: 'preferNativeFetch' }, + // { messageId: 'shouldUseHeaderGetter' }, + // { messageId: 'shouldUseHeaderGetter' }, + // { messageId: 'shouldUseHeaderGetter' }, + // { messageId: 'shouldUseHeaderGetter' }, + // { messageId: 'shouldUseHeaderGetter' }, + // { messageId: 'shouldUseHeaderGetter' }, + // { messageId: 'shouldUseHeaderGetter' }, + // { messageId: 'shouldUseHeaderGetter' }, + // { messageId: 'shouldUseHeaderGetter' }, + // { messageId: 'shouldUseHeaderGetter' }, + // ], + // }, + { + name: 'in non-async arrow function with concurrent promises', + code: ` + await Promise.all( + Object.keys(zoneKeyPartImportRequest).map((propertyName) => { + const requestWithPropertyMissing = omit( + zoneKeyPartImportRequest, + propertyName, + ); + return ( + fixture.api + .put(\`\${BASE_PATH}/zone-key/\${zoneKeyId}\`) + .send(requestWithPropertyMissing) + .expect(StatusCodes.BAD_REQUEST) + ); + }), + ); + `, + output: ` + await Promise.all( + Object.keys(zoneKeyPartImportRequest).map((propertyName) => { + const requestWithPropertyMissing = omit( + zoneKeyPartImportRequest, + propertyName, + ); + return ( + // eslint-disable-next-line @checkdigit/no-promise-instance-method + fetch(\`\${BASE_PATH}/zone-key/\${zoneKeyId}\`, { + method: 'PUT', + body: JSON.stringify(requestWithPropertyMissing), + }).then((res) => { + assert.equal(res.status, StatusCodes.BAD_REQUEST); + return res; + }) + ); + }), + ); + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + ], +}); diff --git a/src/agent/fetch-then.ts b/src/agent/fetch-then.ts new file mode 100644 index 0000000..48d7540 --- /dev/null +++ b/src/agent/fetch-then.ts @@ -0,0 +1,358 @@ +// agent/fetch-then.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { strict as assert } from 'node:assert'; + +// import { ScopeManager, Variable } from '@typescript-eslint/scope-manager'; +import { AST_NODE_TYPES, ESLintUtils, TSESTree } from '@typescript-eslint/utils'; +import type { SourceCode } from '@typescript-eslint/utils/ts-eslint'; + +import { getEnclosingFunction, getParent, isUsedInArrayOrAsArgument } from '../library/ts-tree'; +import getDocumentationUrl from '../get-documentation-url'; +import { getIndentation } from '../library/format'; +import { isValidPropertyName } from '../library/variable'; +import { hasAssertions } from './fetch'; +import { replaceEndpointUrlPrefixWithBasePath } from './url'; + +export const ruleId = 'fetch-then'; + +interface FixtureCallInformation { + fixtureNode: TSESTree.CallExpression; + requestBody?: TSESTree.Expression; + requestHeaders?: { name: TSESTree.Expression; value: TSESTree.Expression }[]; + assertions?: TSESTree.Expression[][]; +} + +// recursively analyze the fixture/supertest call chain to collect information of request/response +function analyzeFixtureCall(call: TSESTree.CallExpression, results: FixtureCallInformation, sourceCode: SourceCode) { + const parent = getParent(call); + if (!parent) { + return; + } + + let nextCall; + if (parent.type !== AST_NODE_TYPES.MemberExpression) { + results.fixtureNode = call; + return; + } + + if (parent.property.type === AST_NODE_TYPES.Identifier) { + if (parent.property.name === 'expect') { + // supertest assertions + const assertionCall = getParent(parent); + assert.ok(assertionCall && assertionCall.type === AST_NODE_TYPES.CallExpression); + results.assertions = [...(results.assertions ?? []), assertionCall.arguments as TSESTree.Expression[]]; + nextCall = assertionCall; + } else if (parent.property.name === 'send') { + // request body + const sendRequestBodyCall = getParent(parent); + assert.ok(sendRequestBodyCall && sendRequestBodyCall.type === AST_NODE_TYPES.CallExpression); + results.requestBody = sendRequestBodyCall.arguments[0] as TSESTree.Expression; + nextCall = sendRequestBodyCall; + } else if (parent.property.name === 'set') { + // request headers + const setRequestHeaderCall = getParent(parent); + assert.ok(setRequestHeaderCall && setRequestHeaderCall.type === AST_NODE_TYPES.CallExpression); + const [name, value] = setRequestHeaderCall.arguments as [TSESTree.Expression, TSESTree.Expression]; + results.requestHeaders = [...(results.requestHeaders ?? []), { name, value }]; + nextCall = setRequestHeaderCall; + } + } else { + throw new Error(`Unexpected TSESTree.Expression in fixture/supertest call ${sourceCode.getText(parent)}.`); + } + if (nextCall) { + analyzeFixtureCall(nextCall, results, sourceCode); + } +} + +// eslint-disable-next-line sonarjs/cognitive-complexity +function createResponseAssertions( + fixtureCallInformation: FixtureCallInformation, + sourceCode: SourceCode, + responseVariableName: string, +) { + let statusAssertion: string | undefined; + const nonStatusAssertions: string[] = []; + for (const expectArguments of fixtureCallInformation.assertions ?? []) { + if (expectArguments.length === 1) { + const [assertionArgument] = expectArguments; + assert.ok(assertionArgument); + if ( + (assertionArgument.type === AST_NODE_TYPES.MemberExpression && + assertionArgument.object.type === AST_NODE_TYPES.Identifier && + assertionArgument.object.name === 'StatusCodes') || + assertionArgument.type === AST_NODE_TYPES.Literal || + sourceCode.getText(assertionArgument).includes('StatusCodes.') + ) { + // status code assertion + statusAssertion = `assert.equal(${responseVariableName}.status, ${sourceCode.getText(assertionArgument)})`; + } else if (assertionArgument.type === AST_NODE_TYPES.ArrowFunctionExpression) { + // callback assertion using arrow function + let functionBody = sourceCode.getText(assertionArgument.body); + + const [originalResponseArgument] = assertionArgument.params; + assert.ok(originalResponseArgument?.type === AST_NODE_TYPES.Identifier); + const originalResponseArgumentName = originalResponseArgument.name; + if (originalResponseArgumentName !== responseVariableName) { + functionBody = functionBody.replace( + new RegExp(`\\b${originalResponseArgumentName}\\b`, 'ug'), + responseVariableName, + ); + } + nonStatusAssertions.push(`assert.doesNotThrow(()=>${functionBody})`); + } else if (assertionArgument.type === AST_NODE_TYPES.Identifier) { + // callback assertion using function reference + nonStatusAssertions.push( + `assert.doesNotThrow(()=>${sourceCode.getText(assertionArgument)}(${responseVariableName}))`, + ); + } else if ( + assertionArgument.type === AST_NODE_TYPES.ObjectExpression || + assertionArgument.type === AST_NODE_TYPES.CallExpression + ) { + // body deep equal assertion + nonStatusAssertions.push( + `assert.deepEqual(await ${responseVariableName}.json(), ${sourceCode.getText(assertionArgument)})`, + ); + } else { + throw new Error(`Unexpected Supertest assertion argument: ".expect(${sourceCode.getText(assertionArgument)})`); + } + } else if (expectArguments.length === 2) { + // header assertion + const [headerName, headerValue] = expectArguments; + assert.ok(headerName && headerValue); + const headersReference = `${responseVariableName}.headers`; + if (headerValue.type === AST_NODE_TYPES.Literal && headerValue.value instanceof RegExp) { + nonStatusAssertions.push( + `assert.ok(${headersReference}.get(${sourceCode.getText(headerName)}).match(${sourceCode.getText(headerValue)}))`, + ); + } else { + nonStatusAssertions.push( + `assert.equal(${headersReference}.get(${sourceCode.getText(headerName)}), ${sourceCode.getText(headerValue)})`, + ); + } + } + } + return { + statusAssertion, + nonStatusAssertions, + }; +} + +// function getResponseHeadersAccesses(responseVariables: Variable[], scopeManager: ScopeManager, sourceCode: SourceCode) { +// const responseHeadersAccesses: TSESTree.MemberExpression[] = []; +// for (const responseVariable of responseVariables) { +// for (const responseReference of responseVariable.references) { +// const responseAccess = getParent(responseReference.identifier); +// if (!responseAccess || responseAccess.type !== AST_NODE_TYPES.MemberExpression) { +// continue; +// } + +// const responseAccessParent = getParent(responseAccess); +// if (!responseAccessParent) { +// continue; +// } + +// if ( +// responseAccessParent.type === AST_NODE_TYPES.CallExpression && +// responseAccessParent.arguments[0]?.type === AST_NODE_TYPES.ArrowFunctionExpression +// ) { +// // map-like operation against responses, e.g. responses.map((response) => response.headers.etag) +// responseHeadersAccesses.push( +// ...getResponseHeadersAccesses( +// scopeManager.getDeclaredVariables(responseAccessParent.arguments[0]), +// scopeManager, +// sourceCode, +// ), +// ); +// continue; +// } + +// if ( +// responseAccess.computed && +// responseAccess.property.type === AST_NODE_TYPES.Literal && +// responseAccessParent.type === AST_NODE_TYPES.MemberExpression +// ) { +// // header access through indexed responses array, e.g. responses[0].headers, responses[1].get(...), etc. +// responseHeadersAccesses.push(responseAccessParent); +// } else { +// responseHeadersAccesses.push(responseAccess); +// } +// } +// } +// return responseHeadersAccesses; +// } + +const createRule = ESLintUtils.RuleCreator((name) => getDocumentationUrl(name)); +const rule: ESLintUtils.RuleModule<'unknownError' | 'preferNativeFetch'> = createRule({ + name: ruleId, + meta: { + type: 'suggestion', + docs: { + description: 'Prefer native fetch API over customized fixture API.', + url: getDocumentationUrl(ruleId), + }, + messages: { + preferNativeFetch: 'Prefer native fetch API over customized fixture API.', + // shouldUseHeaderGetter: 'Getter should be used to access response headers.', + unknownError: 'Unknown error occurred in file "{{fileName}}": {{ error }}.', + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + const scopeManager = sourceCode.scopeManager; + assert.ok(scopeManager); + + return { + 'CallExpression[callee.object.object.name="fixture"][callee.object.property.name="api"]': ( + fixtureCall: TSESTree.CallExpression, + ) => { + try { + if (!hasAssertions(fixtureCall)) { + // skip if there are no assertions, let "no-fixture" rule to handle the conversion + return; + } + + if (!(isUsedInArrayOrAsArgument(fixtureCall) || getEnclosingFunction(fixtureCall)?.async === false)) { + return; + } + + const fixtureFunction = fixtureCall.callee; // e.g. fixture.api.get + assert.ok(fixtureFunction.type === AST_NODE_TYPES.MemberExpression); + const indentation = getIndentation(fixtureCall, sourceCode); + + const [urlArgumentNode] = fixtureCall.arguments; // e.g. `/sample-service/v1/ping` + assert.ok(urlArgumentNode !== undefined); + + const fixtureCallInformation = {} as FixtureCallInformation; + analyzeFixtureCall(fixtureCall, fixtureCallInformation, sourceCode); + + // convert url from `/sample-service/v1/ping` to `${BASE_PATH}/ping` + const originalUrlArgumentText = sourceCode.getText(urlArgumentNode); + const fetchUrlArgumentText = replaceEndpointUrlPrefixWithBasePath(originalUrlArgumentText); + + // fetch request argument + const methodNode = fixtureFunction.property; // get/put/etc. + assert.ok(methodNode.type === AST_NODE_TYPES.Identifier); + const fetchRequestArgumentLines = [ + '{', + ` method: '${methodNode.name.toUpperCase()}',`, + ...(fixtureCallInformation.requestBody + ? [` body: JSON.stringify(${sourceCode.getText(fixtureCallInformation.requestBody)}),`] + : []), + ...(fixtureCallInformation.requestHeaders + ? [ + ` headers: {`, + ...fixtureCallInformation.requestHeaders.map( + ({ name, value }) => + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions, no-nested-ternary, sonarjs/no-nested-template-literals + ` ${name.type === AST_NODE_TYPES.Literal ? (isValidPropertyName(name.value) ? name.value : `'${name.value}'`) : `[${sourceCode.getText(name)}]`}: ${sourceCode.getText(value)},`, + ), + ` },`, + ] + : []), + '}', + ].join(`\n${indentation}`); + + const responseVariableNameToUse = 'res'; + const { statusAssertion, nonStatusAssertions } = createResponseAssertions( + fixtureCallInformation, + sourceCode, + responseVariableNameToUse, + ); + + // add variable declaration if needed + const disableLintComment = '// eslint-disable-next-line @checkdigit/no-promise-instance-method'; + const fetchCallText = `fetch(${fetchUrlArgumentText}, ${fetchRequestArgumentLines})`; + const appendingAssignmentAndAssertionText = [ + ...(statusAssertion !== undefined ? [statusAssertion] : []), + ...nonStatusAssertions, + ].join(`;\n${indentation}`); + const replacementText = fixtureCallInformation.assertions + ? [ + disableLintComment, + `${fetchCallText}.then((${responseVariableNameToUse}) => {`, + appendingAssignmentAndAssertionText === '' ? '' : ` ${appendingAssignmentAndAssertionText};`, + ` return ${responseVariableNameToUse};`, + `})`, + ].join(`\n${indentation}`) + : fetchCallText; + + context.report({ + node: fixtureCall, + messageId: 'preferNativeFetch', + fix(fixer) { + return fixer.replaceText(fixtureCallInformation.fixtureNode, replacementText); + }, + }); + + // const responsesVariable = getEnclosingStatement(fixtureCallInformation.fixtureNode); + // if (!responsesVariable) { + // return; + // } + + // const responseVariableReferences = scopeManager.getDeclaredVariables(responsesVariable); + // const responseHeadersAccesses = getResponseHeadersAccesses( + // responseVariableReferences, + // scopeManager, + // sourceCode, + // ); + // for (const responseHeadersAccess of responseHeadersAccesses) { + // if (isInvalidResponseHeadersAccess(responseHeadersAccess)) { + // const headerAccess = getParent(responseHeadersAccess); + // if (headerAccess?.type === AST_NODE_TYPES.MemberExpression) { + // const headerNameNode = headerAccess.property; + // const headerName = headerAccess.computed + // ? sourceCode.getText(headerNameNode) + // : `'${sourceCode.getText(headerNameNode)}'`; + // const headerAccessReplacementText = `${sourceCode.getText(headerAccess.object)}.get(${headerName})`; + + // context.report({ + // node: headerAccess, + // messageId: 'shouldUseHeaderGetter', + // fix(fixer) { + // return fixer.replaceText(headerAccess, headerAccessReplacementText); + // }, + // }); + // } else if ( + // headerAccess?.type === AST_NODE_TYPES.CallExpression && + // responseHeadersAccess.property.type === AST_NODE_TYPES.Identifier && + // responseHeadersAccess.property.name === 'get' + // ) { + // const headerAccessReplacementText = `${sourceCode.getText(responseHeadersAccess.object)}.headers.get(${sourceCode.getText(headerAccess.arguments[0])})`; + + // context.report({ + // node: headerAccess, + // messageId: 'shouldUseHeaderGetter', + // fix(fixer) { + // return fixer.replaceText(headerAccess, headerAccessReplacementText); + // }, + // }); + // } + // } + // } + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to apply ${ruleId} rule for file "${context.filename}":`, error); + context.report({ + node: fixtureCall, + messageId: 'unknownError', + data: { + fileName: context.filename, + error: error instanceof Error ? error.toString() : JSON.stringify(error), + }, + }); + } + }, + }; + }, +}); + +export default rule; diff --git a/src/agent/fetch.ts b/src/agent/fetch.ts new file mode 100644 index 0000000..0977bbf --- /dev/null +++ b/src/agent/fetch.ts @@ -0,0 +1,71 @@ +// agent/fetch.ts + +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; +import ts from 'typescript'; + +import { getParent, isBlockStatement } from '../library/ts-tree'; + +export function getResponseBodyRetrievalText(responseVariableName: string) { + return `await ${responseVariableName}.json()`; +} + +export function getResponseStatusRetrievalText(responseVariableName: string) { + return `${responseVariableName}.status`; +} + +export function getResponseHeadersRetrievalText(responseVariableName: string) { + return `${responseVariableName}.headers`; +} + +export function isInvalidResponseHeadersAccess(responseHeadersAccess: TSESTree.Node): boolean { + const responseHeaderAccessParent = getParent(responseHeadersAccess); + if (responseHeaderAccessParent?.type === AST_NODE_TYPES.VariableDeclarator) { + return false; + } + + if ( + responseHeaderAccessParent?.type === AST_NODE_TYPES.CallExpression && + responseHeaderAccessParent.callee.type === AST_NODE_TYPES.MemberExpression && + responseHeaderAccessParent.callee.property.type === AST_NODE_TYPES.Identifier && + responseHeaderAccessParent.callee.property.name === 'get' + ) { + return true; + } + + return !( + responseHeaderAccessParent?.type === AST_NODE_TYPES.MemberExpression && + responseHeaderAccessParent.property.type === AST_NODE_TYPES.Identifier && + responseHeaderAccessParent.property.name === 'get' + ); +} + +export function hasAssertions(fixtureCall: TSESTree.Node): boolean { + if (isBlockStatement(fixtureCall)) { + return false; + } + + const parent = getParent(fixtureCall); + if (!parent) { + return false; + } + + if ( + parent.type === AST_NODE_TYPES.MemberExpression && + parent.property.type === AST_NODE_TYPES.Identifier && + parent.property.name === 'expect' && + getParent(parent)?.type === AST_NODE_TYPES.CallExpression + ) { + return true; + } + + return hasAssertions(parent); +} + +export function isFetchResponse(type: ts.Type): boolean { + return ( + type.getProperties().some((symbol) => symbol.name === 'json') && + type.getProperties().some((symbol) => symbol.name === 'status') && + type.getProperties().some((symbol) => symbol.name === 'headers') && + type.getProperties().some((symbol) => symbol.name === 'body') + ); +} diff --git a/src/agent/file.spec.ts b/src/agent/file.spec.ts new file mode 100644 index 0000000..4fbfe9f --- /dev/null +++ b/src/agent/file.spec.ts @@ -0,0 +1,52 @@ +// agent/file.spec.ts + +import { strict as assert } from 'node:assert'; +import { describe, it } from '@jest/globals'; +import { + getApiFolder, + getApiIndexPathByFilename, + getProjectRootFolder, + getSwaggerPathByIndexFile, + isApiIndexFile, +} from './file'; + +describe('file utility functions', () => { + it('isApiIndexFile', () => { + assert(isApiIndexFile('/Users/xxx/workspace/src/api/v1/index.ts')); + assert(!isApiIndexFile('/Users/xxx/workspace/src/api/v1/ping.ts')); + assert(!isApiIndexFile('/Users/xxx/workspace/src/api/v1/test/index.ts')); + }); + + it('getProjectRootFolder', () => { + assert.equal(getProjectRootFolder('/Users/xxx/workspace/src/index.ts'), '/Users/xxx/workspace'); + assert.equal(getProjectRootFolder('/Users/xxx/workspace/src/util/pgp.ts'), '/Users/xxx/workspace'); + assert.equal(getProjectRootFolder('/Users/xxx/workspace/src/api/v1/index.ts'), '/Users/xxx/workspace'); + assert.equal(getProjectRootFolder('/Users/xxx/workspace/src/api/v1/ping.ts'), '/Users/xxx/workspace'); + assert.equal(getProjectRootFolder('/Users/xxx/workspace'), ''); + }); + + it('getSwaggerPathByIndexFile', () => { + assert.equal( + getSwaggerPathByIndexFile('/Users/xxx/workspace/src/api/v1/index.ts'), + '/Users/xxx/workspace/src/api/v1/swagger.yml', + ); + }); + + it('getApiFolder', () => { + assert.equal(getApiFolder('src/api/v1/index.ts'), 'src/api/v1'); + assert.equal(getApiFolder('src/api/v1/ping.ts'), 'src/api/v1'); + assert.equal(getApiFolder('src/api/v1/service/abc.ts'), 'src/api/v1'); + + assert.equal(getApiFolder('/Users/xxx/workspace/src/api/v1/index.ts'), '/Users/xxx/workspace/src/api/v1'); + assert.equal(getApiFolder('/Users/xxx/workspace/src/api/v1/ping.ts'), '/Users/xxx/workspace/src/api/v1'); + assert.equal(getApiFolder('/Users/xxx/workspace/src/api/v1/service/abc.ts'), '/Users/xxx/workspace/src/api/v1'); + + assert.equal(getApiFolder('/Users/xxx/workspace/src/abc.ts'), undefined); + }); + + it('getApiIndexPathByFilename', () => { + assert.equal(getApiIndexPathByFilename('/Users/xxx/workspace/src/api/v1/ping.ts'), './index'); + assert.equal(getApiIndexPathByFilename('/Users/xxx/workspace/src/api/v1/service/abc.ts'), '../index'); + assert.equal(getApiIndexPathByFilename('/Users/xxx/workspace/src/api/v1/service/util/abc.ts'), '../../index'); + }); +}); diff --git a/src/agent/file.ts b/src/agent/file.ts new file mode 100644 index 0000000..7f33d67 --- /dev/null +++ b/src/agent/file.ts @@ -0,0 +1,42 @@ +// agent/file.ts + +import fs from 'node:fs'; +import path from 'node:path'; + +export function isApiIndexFile(filename: string): boolean { + return /.*\/src\/api\/v\d+\/index.ts/u.test(filename); +} + +export function getProjectRootFolder(indexFilename: string): string { + return indexFilename.substring(0, indexFilename.lastIndexOf('/src/')); +} + +export function getSwaggerPathByIndexFile(indexFilename: string): string { + return indexFilename.replace(/index\.ts$/u, 'swagger.yml'); +} + +export function loadSwagger(filename: string): string { + return fs.readFileSync(filename, 'utf8'); +} + +export function loadPackageJson(projectRoot: string): string { + return fs.readFileSync(`${projectRoot}/package.json`, 'utf8'); +} + +export function getApiFolder(folder: string): string | undefined { + if (/^\/?(?(?:[^/]+\/)*)src\/api\/v\d+$/u.test(folder)) { + return folder; + } + const upperFolder = folder.substring(0, folder.lastIndexOf('/')); + return upperFolder.trim() === '' ? undefined : getApiFolder(upperFolder); +} + +export function getApiIndexPathByFilename(filename: string): string | undefined { + const apiFolder = getApiFolder(filename); + if (apiFolder === undefined) { + return undefined; + } + + const relativePath = path.relative(path.dirname(filename), `${apiFolder}/index`); + return relativePath.startsWith('../') ? relativePath : `./${relativePath}`; +} diff --git a/src/agent/fix-function-call-arguments.spec.ts b/src/agent/fix-function-call-arguments.spec.ts new file mode 100644 index 0000000..83eb8d7 --- /dev/null +++ b/src/agent/fix-function-call-arguments.spec.ts @@ -0,0 +1,128 @@ +// agent/fix-function-call-arguments.spec.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import createTester from '../ts-tester.test'; +import rule, { type FixFunctionCallArgumentsRuleOptions, ruleId } from './fix-function-call-arguments'; + +const testOptions: FixFunctionCallArgumentsRuleOptions = { typesToCheck: ['string', 'number', 'object'] }; +createTester().run(ruleId, rule, { + valid: [ + { + name: 'correct function call', + options: [testOptions], + code: ` + function doSomething(id:string, count:number) { + // do something + }; + const param1: string = 'abc'; + const param2: number = 2; + doSomething(param1, param2); + `, + }, + { + name: 'regular node library call should not be affected', + options: [testOptions], + code: `Buffer.from('some data', 'base64')`, + }, + { + name: 'regular node assertion call should not be affected', + options: [testOptions], + code: ` + import { strict as assert } from 'node:assert'; + const valueA = 'abc'; + assert.equal(valueA, 'abc'); + `, + }, + ], + invalid: [ + { + name: 'remove incompatible function arguments', + options: [testOptions], + code: ` + function doSomething(id:string, count:number) { + // do something + }; + const param1: number = 1; + const param2: string = 'abc'; + const param3: number = 2; + doSomething(param1, param2, param3); + `, + output: ` + function doSomething(id:string, count:number) { + // do something + }; + const param1: number = 1; + const param2: string = 'abc'; + const param3: number = 2; + doSomething(param2, param3); + `, + errors: [{ messageId: 'removeIncompatibleFunctionArguments' }], + }, + { + name: 'remove incompatible function arguments - handle the ending comma', + options: [testOptions], + code: ` + function doSomething(id:string, count:number) { + // do something + }; + const param1: number = 1; + doSomething(param1,); + `, + output: ` + function doSomething(id:string, count:number) { + // do something + }; + const param1: number = 1; + doSomething(); + `, + errors: [{ messageId: 'removeIncompatibleFunctionArguments' }], + }, + { + name: 'remove incompatible function arguments - original function has no arguments', + options: [testOptions], + code: ` + function doSomething() { + // do something + }; + const param1: number = 1; + doSomething(param1); + `, + output: ` + function doSomething() { + // do something + }; + const param1: number = 1; + doSomething(); + `, + errors: [{ messageId: 'removeIncompatibleFunctionArguments' }], + }, + { + name: 'remove incompatible function arguments - original function has less arguments', + options: [testOptions], + code: ` + function doSomething(id: string) { + // do something + }; + const param1: number = 1; + const param2: string = 'abc'; + const param3: number = 2; + doSomething(param1, param2, param3); + `, + output: ` + function doSomething(id: string) { + // do something + }; + const param1: number = 1; + const param2: string = 'abc'; + const param3: number = 2; + doSomething(param2); + `, + errors: [{ messageId: 'removeIncompatibleFunctionArguments' }], + }, + ], +}); diff --git a/src/agent/fix-function-call-arguments.ts b/src/agent/fix-function-call-arguments.ts new file mode 100644 index 0000000..bb3cb75 --- /dev/null +++ b/src/agent/fix-function-call-arguments.ts @@ -0,0 +1,200 @@ +// agent/fix-function-call-arguments.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { strict as assert } from 'node:assert'; +import { ESLintUtils, TSESTree } from '@typescript-eslint/utils'; +import debug from 'debug'; +import getDocumentationUrl from '../get-documentation-url'; + +export const ruleId = 'fix-function-call-arguments'; + +export interface FixFunctionCallArgumentsRuleOptions { + typesToCheck: string[]; +} +const DEFAULT_OPTIONS = { + typesToCheck: [ + 'Configuration', + 'Fixture', + 'InboundContext', + '{ get: () => string; }', + 'Api', + ], +}; + +const createRule = ESLintUtils.RuleCreator((name) => getDocumentationUrl(name)); +const log = debug('eslint-plugin:fix-function-call-arguments'); + +const rule: ESLintUtils.RuleModule< + 'removeIncompatibleFunctionArguments' | 'unknownError', + [FixFunctionCallArgumentsRuleOptions] +> = createRule({ + name: ruleId, + meta: { + type: 'suggestion', + docs: { + description: 'Remove incompatible function arguments.', + }, + messages: { + removeIncompatibleFunctionArguments: 'Removing incompatible function arguments.', + unknownError: 'Unknown error occurred in file "{{fileName}}": {{ error }}.', + }, + fixable: 'code', + schema: [ + { + type: 'object', + properties: { + typesToCheck: { + description: 'Text representation of the types of which the function call parameters will be examine', + type: 'array', + items: { + type: 'string', + }, + }, + }, + additionalProperties: false, + }, + ], + }, + defaultOptions: [DEFAULT_OPTIONS], + create(context) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const { typesToCheck } = context.options[0] ?? DEFAULT_OPTIONS; + const parserServices = ESLintUtils.getParserServices(context); + const typeChecker = parserServices.program.getTypeChecker(); + const sourceCode = context.sourceCode; + + return { + CallExpression(callExpression) { + // ignore calls like `foo.bar()` which are likely to be 3rd party module calls + // we only focus on calls against local functions or functions imported from the same module + // if (callExpression.callee.type === TSESTree.AST_NODE_TYPES.MemberExpression) { + // return; + // } + + log('===== file name:', context.filename); + log('callExpression:', sourceCode.getText(callExpression)); + + try { + const actualParameters = callExpression.arguments; + if ( + !actualParameters.some((actualParameter) => { + const actualType = typeChecker.getTypeAtLocation( + parserServices.esTreeNodeToTSNodeMap.get(actualParameter), + ); + const actualTypeString = typeChecker.typeToString(actualType); + return typesToCheck.includes(actualTypeString) || actualTypeString.endsWith('RequestType'); + }) + ) { + return; + } + + const calleeTsNode = parserServices.esTreeNodeToTSNodeMap.get(callExpression.callee); + const calleeType = typeChecker.getTypeAtLocation(calleeTsNode); + + const signatures = calleeType.getCallSignatures(); + if (signatures.length > 1) { + // ignore complex signatures with overloads + return; + } + + const signature = signatures[0]; + assert(signature); + // if ( + // signature === undefined || + // (signature.typeParameters !== undefined && signature.typeParameters.length > 0) + // ) { + // // ignore complex signatures with type parameters + // return; + // } + + log('signature:', signature.getDeclaration().getText()); + const expectedParameters = signature.getParameters(); + log( + 'expected parameters:', + expectedParameters.map((expectedParameter) => + typeChecker.typeToString(typeChecker.getTypeOfSymbol(expectedParameter)), + ), + ); + const expectedParametersCount = expectedParameters.length; + const actualParametersCount = actualParameters.length; + if (actualParametersCount === 0) { + return; + } + + const parametersToKeep: TSESTree.CallExpressionArgument[] = []; + let expectedParameterIndex = 0; + for (const [actualParameterIndex, actualParameter] of actualParameters.entries()) { + if (expectedParameterIndex >= expectedParametersCount) { + break; + } + + const expectedParameter = expectedParameters[expectedParameterIndex]; + assert.ok(expectedParameter, 'Expected parameter not found.'); + + const expectedType = typeChecker.getTypeOfSymbol(expectedParameter); + const actualType = typeChecker.getTypeAtLocation(parserServices.esTreeNodeToTSNodeMap.get(actualParameter)); + const actualTypeString = typeChecker.typeToString(actualType); + log( + 'expected type: #', + expectedParameterIndex, + expectedParameter.escapedName, + typeChecker.typeToString(expectedType), + ); + log('actual type: #', actualParameterIndex, sourceCode.getText(actualParameter), actualTypeString); + + if ( + (typesToCheck.includes(actualTypeString) || actualTypeString.endsWith('RequestType')) && + !typeChecker.isTypeAssignableTo(actualType, expectedType) + ) { + log('removing un-matched parameter', sourceCode.getText(actualParameter)); + continue; + } + parametersToKeep.push(actualParameter); + expectedParameterIndex++; + } + + if (parametersToKeep.length === actualParametersCount) { + return; + } + + const firstParameter = actualParameters[0]; + const lastParameter = actualParameters.at(-1); + assert.ok(firstParameter !== undefined && lastParameter !== undefined); + const tokenAfterParameters = sourceCode.getTokenAfter(lastParameter); + + context.report({ + node: callExpression, + messageId: 'removeIncompatibleFunctionArguments', + fix(fixer) { + return fixer.replaceTextRange( + [ + firstParameter.range[0], + tokenAfterParameters?.value === ',' ? tokenAfterParameters.range[1] : lastParameter.range[1], + ], + parametersToKeep.map((arg) => sourceCode.getText(arg)).join(', '), + ); + }, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to apply ${ruleId} rule for file "${context.filename}":`, error); + context.report({ + node: callExpression, + messageId: 'unknownError', + data: { + fileName: context.filename, + error: error instanceof Error ? error.toString() : JSON.stringify(error), + }, + }); + } + }, + }; + }, +}); + +export default rule; diff --git a/src/agent/no-expect-assertion.spec.ts b/src/agent/no-expect-assertion.spec.ts new file mode 100644 index 0000000..8733a2d --- /dev/null +++ b/src/agent/no-expect-assertion.spec.ts @@ -0,0 +1,503 @@ +// agent/no-expect-assertion.spec.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import createTester from '../ts-tester.test'; +import rule, { ruleId } from './no-expect-assertion'; + +createTester().run(ruleId, rule, { + valid: [ + { + name: 'leave non-API calls as is', + code: ` + function foo() { + return 'bar'; + } + async function test() { + foo().expect(StatusCodes.OK); + }`, + }, + { + name: 'leave fixture.api.xxx() calls as is, which will wait to be converted to fetch calls first', + code: ` + async function test() { + await fixture.api.get('/ping/v1/ping').expect(StatusCodes.OK); + }`, + }, + ], + invalid: [ + { + name: 'assertion without variable declaration', + code: `async function test() { + await fetch('/ping/v1/ping').expect(StatusCodes.OK); + }`, + output: `async function test() { + const pingGetResponse = await fetch('/ping/v1/ping'); + assert.equal(pingGetResponse.status, StatusCodes.OK); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'assertion without variable declaration - with status code as number instead of StatusCodes enum value', + code: `async function test() { + await fetch('/ping/v1/ping').expect(200); + }`, + output: `async function test() { + const pingGetResponse = await fetch('/ping/v1/ping'); + assert.equal(pingGetResponse.status, 200); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'assertion without variable declaration - with url as template literal', + code: `async function test() { + await fetch(\`\${BASE_PATH}/ping\`).expect(StatusCodes.OK); + }`, + output: `async function test() { + const pingGetResponse = await fetch(\`\${BASE_PATH}/ping\`); + assert.equal(pingGetResponse.status, StatusCodes.OK); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'assertion without variable declaration - with RequestInit argument', + code: `async function test() { + await fetch('/ping/v1/ping', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + }).expect(StatusCodes.OK); + }`, + output: `async function test() { + const pingPutResponse = await fetch('/ping/v1/ping', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + }); + assert.equal(pingPutResponse.status, StatusCodes.OK); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'assertion without variable declaration - using utility function instead of fetch', + code: `function ping() { + return fetch('/ping/v1/ping'); + } + async function test() { + await ping().expect(StatusCodes.OK); + }`, + output: `function ping() { + return fetch('/ping/v1/ping'); + } + async function test() { + const pingResponse = await ping(); + assert.equal(pingResponse.status, StatusCodes.OK); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'assertion without variable declaration - using utility function with nested reference', + code: `function ping() { + return fetch('/ping/v1/ping'); + } + const util = { ping }; + async function test() { + await util.ping().expect(StatusCodes.OK); + }`, + output: `function ping() { + return fetch('/ping/v1/ping'); + } + const util = { ping }; + async function test() { + const pingResponse = await util.ping(); + assert.equal(pingResponse.status, StatusCodes.OK); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'assertion with variable declaration', + code: `async function test() { + const pingResponse = await fetch('/ping/v1/ping').expect(StatusCodes.OK); + assert(pingResponse); + }`, + output: `async function test() { + const pingResponse = await fetch('/ping/v1/ping'); + assert.equal(pingResponse.status, StatusCodes.OK); + assert(pingResponse); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'response headers assertion', + code: `async function test() { + await fetch('/ping/v1/ping') + .expect(StatusCodes.OK) + .expect('etag', '123') + .expect('content-type', 'application/json') + .expect(ETAG, correctVersion) + .expect(ETAG, /1.*/u); + }`, + output: `async function test() { + const pingGetResponse = await fetch('/ping/v1/ping'); + assert.equal(pingGetResponse.status, StatusCodes.OK); + assert.equal(pingGetResponse.headers.get('etag'), '123'); + assert.equal(pingGetResponse.headers.get('content-type'), 'application/json'); + assert.equal(pingGetResponse.headers.get(ETAG), correctVersion); + assert.ok(pingGetResponse.headers.get(ETAG).match(/1.*/u)); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'response body assertion', + code: `async function test() { + await fetch('/ping/v1/ping').expect({message:'pong'}); + }`, + output: `async function test() { + const pingGetResponse = await fetch('/ping/v1/ping'); + assert.deepEqual(await pingGetResponse.json(), {message:'pong'}); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'response callback assertion', + code: `async function test() { + await fetch('/ping/v1/ping') + .expect(validate) + .expect((response)=>console.log(response)); + }`, + output: `async function test() { + const pingGetResponse = await fetch('/ping/v1/ping'); + assert.doesNotThrow(()=>validate(pingGetResponse)); + assert.doesNotThrow(()=>console.log(pingGetResponse)); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'multiple fetch calls in the same test', + code: `function ping() { + return fetch('/ping/v1/ping'); + } + async function test() { + await ping().expect(StatusCodes.OK); + const pingResponse = await ping().expect(StatusCodes.OK); + await ping().expect(StatusCodes.OK).expect({message:'pong'}); + await ping().expect(StatusCodes.OK); + }`, + output: `function ping() { + return fetch('/ping/v1/ping'); + } + async function test() { + const pingResponse1 = await ping(); + assert.equal(pingResponse1.status, StatusCodes.OK); + const pingResponse = await ping(); + assert.equal(pingResponse.status, StatusCodes.OK); + const pingResponse2 = await ping(); + assert.equal(pingResponse2.status, StatusCodes.OK); + assert.deepEqual(await pingResponse2.json(), {message:'pong'}); + const pingResponse3 = await ping(); + assert.equal(pingResponse3.status, StatusCodes.OK); + }`, + errors: [ + { messageId: 'preferNativeFetch' }, + { messageId: 'preferNativeFetch' }, + { messageId: 'preferNativeFetch' }, + { messageId: 'preferNativeFetch' }, + ], + }, + { + name: 'response variable names in different scope do not conflict with each other', + code: `function ping() { + return fetch('/ping/v1/ping'); + } + it('#1', async () => { + const pingResponse = 'foo'; + }); + it('#2', async () => { + const pingResponse = 'foo'; + await ping().expect(StatusCodes.OK); + }); + it('#3', async () => { + const pingResponse3 = 'foo'; + await ping().expect(StatusCodes.OK); + }); + `, + output: `function ping() { + return fetch('/ping/v1/ping'); + } + it('#1', async () => { + const pingResponse = 'foo'; + }); + it('#2', async () => { + const pingResponse = 'foo'; + const pingResponse1 = await ping(); + assert.equal(pingResponse1.status, StatusCodes.OK); + }); + it('#3', async () => { + const pingResponse3 = 'foo'; + const pingResponse = await ping(); + assert.equal(pingResponse.status, StatusCodes.OK); + }); + `, + errors: [{ messageId: 'preferNativeFetch' }, { messageId: 'preferNativeFetch' }], + }, + { + name: 'directly return (no await) fetch call with assertion', + code: `async function test() { + return fetch('/ping/v1/ping').expect(StatusCodes.OK); + }`, + output: `async function test() { + const pingGetResponse = await fetch('/ping/v1/ping'); + assert.equal(pingGetResponse.status, StatusCodes.OK); + return pingGetResponse; + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'assert response body against function call return value', + code: `async function test() { + const createdOn = Date.now().toUTCString(); + await fetch('/ping/v1/ping').expect(200).expect(validateBody(createdOn)); + }`, + output: `async function test() { + const createdOn = Date.now().toUTCString(); + const pingGetResponse = await fetch('/ping/v1/ping'); + assert.equal(pingGetResponse.status, 200); + assert.deepEqual(await pingGetResponse.json(), validateBody(createdOn)); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'handle destructuring variable declaration for body', + code: `async function test() { + const { body: responseBody } = await fetch('/ping/v1/ping').expect(StatusCodes.OK); + const timeDifference = Date.now() - new Date(responseBody.serverTime).getTime(); + assert.ok(timeDifference >= 0 && timeDifference < 200); + }`, + output: `async function test() { + const pingGetResponse = await fetch('/ping/v1/ping'); + assert.equal(pingGetResponse.status, StatusCodes.OK); + const responseBody = await pingGetResponse.json(); + const timeDifference = Date.now() - new Date(responseBody.serverTime).getTime(); + assert.ok(timeDifference >= 0 && timeDifference < 200); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'handle destructuring variable declaration for body - with nested destructuring', + code: `async function test() { + const { body: { pgpPublicKey: firstPgpPublicKey } } = await fetch('/ping/v1/ping').expect(StatusCodes.OK); + assert.ok(firstPgpPublicKey.startsWith('-----BEGIN PGP PUBLIC KEY BLOCK-----')); + }`, + output: `async function test() { + const pingGetResponse = await fetch('/ping/v1/ping'); + assert.equal(pingGetResponse.status, StatusCodes.OK); + const { pgpPublicKey: firstPgpPublicKey } = await pingGetResponse.json(); + assert.ok(firstPgpPublicKey.startsWith('-----BEGIN PGP PUBLIC KEY BLOCK-----')); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'handle destructuring variable declaration for headers when body is presented as well', + code: `async function test() { + const { body, headers: headers2 } = await fetch('/ping/v1/ping').expect(StatusCodes.OK); + assert(body); + assert.ok(headers2.get(ETAG)); + }`, + output: `async function test() { + const pingGetResponse = await fetch('/ping/v1/ping'); + assert.equal(pingGetResponse.status, StatusCodes.OK); + const body = await pingGetResponse.json(); + const headers2 = pingGetResponse.headers; + assert(body); + assert.ok(headers2.get(ETAG)); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'handle destructuring variable declaration for headers without body presented but with assertions used', + code: `async function test() { + const { header } = await fetch('/ping/v1/ping').expect(StatusCodes.OK); + assert.ok(header.get(ETAG)); + }`, + output: `async function test() { + const pingGetResponse = await fetch('/ping/v1/ping'); + assert.equal(pingGetResponse.status, StatusCodes.OK); + const header = pingGetResponse.headers; + assert.ok(header.get(ETAG)); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'nested header destructuring', + code: `async function test() { + const { headers: { etag } } = await fetch('/ping/v1/ping').expect(StatusCodes.OK); + }`, + output: `async function test() { + const pingGetResponse = await fetch('/ping/v1/ping'); + assert.equal(pingGetResponse.status, StatusCodes.OK); + const etag = pingGetResponse.headers.get('etag'); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'nested header destructuring - string literal key with renaming', + code: `async function test() { + const { headers: { 'created-on': createdOn, 'updated-on': updatedOn } } = await fetch('/ping/v1/ping').expect(StatusCodes.OK); + }`, + output: `async function test() { + const pingGetResponse = await fetch('/ping/v1/ping'); + assert.equal(pingGetResponse.status, StatusCodes.OK); + const createdOn = pingGetResponse.headers.get('created-on'); + const updatedOn = pingGetResponse.headers.get('updated-on'); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'inline access to response body should be extracted to a variable', + code: `async function test() { + const paymentSecurityServicePublicKey = (await fetch('/ping/v1/ping').expect(StatusCodes.OK)).body.publicKey; + }`, + output: `async function test() { + const pingGetResponse = await fetch('/ping/v1/ping'); + assert.equal(pingGetResponse.status, StatusCodes.OK); + const pingGetResponseBody = await pingGetResponse.json(); + const paymentSecurityServicePublicKey = pingGetResponseBody.publicKey; + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'callback assertion using arrow function that accesses to response might conflict with the new/redefined response variable', + code: `async function test() { + const createdOn = new Date().toISOString(); + const zoneKeyId = uuid(); + + // Import Key + const keyId = uuid(); + const pingResponse = await fetch('/ping/v1/ping') + .expect(StatusCodes.NO_CONTENT) + .expect(ETAG_HEADER, '1') + .expect((res) => verifyTemporalHeaders(res, createdOn)); + }`, + output: `async function test() { + const createdOn = new Date().toISOString(); + const zoneKeyId = uuid(); + + // Import Key + const keyId = uuid(); + const pingResponse = await fetch('/ping/v1/ping'); + assert.equal(pingResponse.status, StatusCodes.NO_CONTENT); + assert.equal(pingResponse.headers.get(ETAG_HEADER), '1'); + assert.doesNotThrow(()=>verifyTemporalHeaders(pingResponse, createdOn)); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'assignment statement instead of variable declaration used for subsequent fixture calls', + code: `async function test() { + let response = await fetch('/ping/v1/ping').expect(StatusCodes.OK); + response = await fetch('/ping/v1/ping').expect(StatusCodes.OK); + }`, + output: `async function test() { + let response = await fetch('/ping/v1/ping'); + assert.equal(response.status, StatusCodes.OK); + response = await fetch('/ping/v1/ping'); + assert.equal(response.status, StatusCodes.OK); + }`, + errors: [{ messageId: 'preferNativeFetch' }, { messageId: 'preferNativeFetch' }], + }, + { + name: 'statusCode destructuring should be renamed', + code: `async function test() { + const { statusCode } = await fetch('/ping/v1/ping').expect(StatusCodes.OK); + }`, + output: `async function test() { + const pingGetResponse = await fetch('/ping/v1/ping'); + assert.equal(pingGetResponse.status, StatusCodes.OK); + const statusCode = pingGetResponse.status; + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'statusCode destructuring should be renamed - with renaming in ObjectPattern', + code: `async function test() { + const { statusCode: pingStatusCode } = await fetch('/ping/v1/ping').expect(StatusCodes.OK); + }`, + output: `async function test() { + const pingGetResponse = await fetch('/ping/v1/ping'); + assert.equal(pingGetResponse.status, StatusCodes.OK); + const pingStatusCode = pingGetResponse.status; + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'inside Promise.all', + code: ` + const responses = await Promise.all([ + fetch('/ping/v1/ping').expect(StatusCodes.NO_CONTENT), + fetch('/ping/v1/ping').expect(StatusCodes.NO_CONTENT), + ]); + `, + output: ` + const responses = await Promise.all([ + // eslint-disable-next-line @checkdigit/no-promise-instance-method + fetch('/ping/v1/ping').then((res) => { + assert.equal(res.status, StatusCodes.NO_CONTENT); + return res; + }), + // eslint-disable-next-line @checkdigit/no-promise-instance-method + fetch('/ping/v1/ping').then((res) => { + assert.equal(res.status, StatusCodes.NO_CONTENT); + return res; + }), + ]); + `, + errors: [{ messageId: 'preferNativeFetch' }, { messageId: 'preferNativeFetch' }], + }, + { + name: 'in non-async arrow function with concurrent promises', + code: ` + await Promise.all( + Object.keys(zoneKeyPartImportRequest).map((propertyName) => { + const requestWithPropertyMissing = omit( + zoneKeyPartImportRequest, + propertyName, + ); + return ( + fetch(\`\${BASE_PATH}/zone-key/\${zoneKeyId}\`, { + method: 'PUT', + body: JSON.stringify(requestWithPropertyMissing), + }).expect(StatusCodes.BAD_REQUEST) + ); + }), + ); + `, + output: ` + await Promise.all( + Object.keys(zoneKeyPartImportRequest).map((propertyName) => { + const requestWithPropertyMissing = omit( + zoneKeyPartImportRequest, + propertyName, + ); + return ( + // eslint-disable-next-line @checkdigit/no-promise-instance-method + fetch(\`\${BASE_PATH}/zone-key/\${zoneKeyId}\`, { + method: 'PUT', + body: JSON.stringify(requestWithPropertyMissing), + }).then((res) => { + assert.equal(res.status, StatusCodes.BAD_REQUEST); + return res; + }) + ); + }), + ); + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + ], +}); diff --git a/src/agent/no-expect-assertion.ts b/src/agent/no-expect-assertion.ts new file mode 100644 index 0000000..20f0149 --- /dev/null +++ b/src/agent/no-expect-assertion.ts @@ -0,0 +1,570 @@ +// agent/no-expect-assertion.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { strict as assert } from 'node:assert'; + +import { AST_NODE_TYPES, ESLintUtils, TSESTree } from '@typescript-eslint/utils'; +import type { Scope, ScopeManager, Variable } from '@typescript-eslint/scope-manager'; +import type { SourceCode } from '@typescript-eslint/utils/ts-eslint'; +import ts from 'typescript'; + +import { + getEnclosingFunction, + getEnclosingScopeNode, + getEnclosingStatement, + getParent, + isUsedInArrayOrAsArgument, +} from '../library/ts-tree'; +import getDocumentationUrl from '../get-documentation-url'; +import { getIndentation } from '../library/format'; +import { analyzeResponseReferences } from './response-reference'; +import { + getResponseBodyRetrievalText, + getResponseHeadersRetrievalText, + getResponseStatusRetrievalText, + isFetchResponse, +} from './fetch'; + +export const ruleId = 'no-expect-assertion'; + +interface FixtureCallInformation { + rootNode: + | TSESTree.AwaitExpression + | TSESTree.ReturnStatement + | TSESTree.VariableDeclaration + | TSESTree.CallExpression + | TSESTree.ExpressionStatement; + fixtureNode: TSESTree.AwaitExpression | TSESTree.CallExpression; + variableDeclaration?: TSESTree.VariableDeclaration; + variableAssignment?: TSESTree.ExpressionStatement; + assertions?: TSESTree.Expression[][]; + inlineStatementNode?: TSESTree.Node; + inlineBodyReference?: TSESTree.MemberExpression; + inlineStatusReference?: TSESTree.MemberExpression; + inlineHeadersReference?: TSESTree.MemberExpression; +} + +// recursively analyze the fixture/supertest call chain to collect information of request/response +// eslint-disable-next-line sonarjs/cognitive-complexity +function analyzeFixtureCall(call: TSESTree.CallExpression, results: FixtureCallInformation, sourceCode: SourceCode) { + const parent = getParent(call); + assert.ok(parent, 'parent should exist for fixture/supertest call node'); + + let nextCall; + if (parent.type === AST_NODE_TYPES.ReturnStatement) { + // direct return, no variable declaration or await + results.fixtureNode = call; + results.rootNode = parent; + } else if ( + parent.type === AST_NODE_TYPES.ArrayExpression || + parent.type === AST_NODE_TYPES.CallExpression || + parent.type === AST_NODE_TYPES.ArrowFunctionExpression + ) { + // direct return, no variable declaration or await + results.fixtureNode = call; + results.rootNode = call; + } else if (parent.type === AST_NODE_TYPES.AwaitExpression) { + results.fixtureNode = call; + const enclosingStatement = getEnclosingStatement(parent); + assert.ok(enclosingStatement); + const awaitParent = getParent(parent); + if (awaitParent?.type === AST_NODE_TYPES.MemberExpression) { + results.rootNode = parent; + results.inlineStatementNode = enclosingStatement; + if (awaitParent.property.type === AST_NODE_TYPES.Identifier && awaitParent.property.name === 'body') { + results.inlineBodyReference = awaitParent; + } + if ( + awaitParent.property.type === AST_NODE_TYPES.Identifier && + (awaitParent.property.name === 'status' || awaitParent.property.name === 'statusCode') + ) { + results.inlineStatusReference = awaitParent; + } + if ( + awaitParent.property.type === AST_NODE_TYPES.Identifier && + (awaitParent.property.name === 'header' || awaitParent.property.name === 'headers') + ) { + results.inlineHeadersReference = awaitParent; + } + } else if (enclosingStatement.type === AST_NODE_TYPES.VariableDeclaration) { + results.variableDeclaration = enclosingStatement; + results.rootNode = enclosingStatement; + } else if ( + enclosingStatement.type === AST_NODE_TYPES.ExpressionStatement && + enclosingStatement.expression.type === AST_NODE_TYPES.AssignmentExpression + ) { + results.variableAssignment = enclosingStatement; + results.rootNode = enclosingStatement; + } else { + results.rootNode = parent; + } + } else if (parent.type === AST_NODE_TYPES.MemberExpression && parent.property.type === AST_NODE_TYPES.Identifier) { + if (parent.property.name === 'expect') { + // supertest assertions + const assertionCall = getParent(parent); + assert.ok(assertionCall && assertionCall.type === AST_NODE_TYPES.CallExpression); + results.assertions = [...(results.assertions ?? []), assertionCall.arguments as TSESTree.Expression[]]; + nextCall = assertionCall; + } + } else { + throw new Error(`Unexpected expression in fixture/supertest call ${sourceCode.getText(parent)}.`); + } + if (nextCall) { + analyzeFixtureCall(nextCall, results, sourceCode); + } +} + +// eslint-disable-next-line sonarjs/cognitive-complexity +function createResponseAssertions( + fixtureCallInformation: FixtureCallInformation, + sourceCode: SourceCode, + responseVariableName: string, + destructuringResponseHeadersVariable: Variable | undefined, +) { + let statusAssertion: string | undefined; + const nonStatusAssertions: string[] = []; + for (const expectArguments of fixtureCallInformation.assertions ?? []) { + if (expectArguments.length === 1) { + const [assertionArgument] = expectArguments; + assert.ok(assertionArgument); + if ( + (assertionArgument.type === AST_NODE_TYPES.MemberExpression && + assertionArgument.object.type === AST_NODE_TYPES.Identifier && + assertionArgument.object.name === 'StatusCodes') || + assertionArgument.type === AST_NODE_TYPES.Literal || + sourceCode.getText(assertionArgument).includes('StatusCodes.') + ) { + // status code assertion + statusAssertion = `assert.equal(${responseVariableName}.status, ${sourceCode.getText(assertionArgument)})`; + } else if (assertionArgument.type === AST_NODE_TYPES.ArrowFunctionExpression) { + // callback assertion using arrow function + let functionBody = sourceCode.getText(assertionArgument.body); + + const [originalResponseArgument] = assertionArgument.params; + assert.ok(originalResponseArgument?.type === AST_NODE_TYPES.Identifier); + const originalResponseArgumentName = originalResponseArgument.name; + if (originalResponseArgumentName !== responseVariableName) { + functionBody = functionBody.replace( + new RegExp(`\\b${originalResponseArgumentName}\\b`, 'ug'), + responseVariableName, + ); + } + nonStatusAssertions.push(`assert.doesNotThrow(()=>${functionBody})`); + } else if (assertionArgument.type === AST_NODE_TYPES.Identifier) { + // callback assertion using function reference + nonStatusAssertions.push( + `assert.doesNotThrow(()=>${sourceCode.getText(assertionArgument)}(${responseVariableName}))`, + ); + } else if ( + assertionArgument.type === AST_NODE_TYPES.ObjectExpression || + assertionArgument.type === AST_NODE_TYPES.CallExpression + ) { + // body deep equal assertion + nonStatusAssertions.push( + `assert.deepEqual(await ${responseVariableName}.json(), ${sourceCode.getText(assertionArgument)})`, + ); + } else { + throw new Error(`Unexpected Supertest assertion argument: ".expect(${sourceCode.getText(assertionArgument)})`); + } + } else if (expectArguments.length === 2) { + // header assertion + const [headerName, headerValue] = expectArguments; + assert.ok(headerName && headerValue); + const headersReference = + destructuringResponseHeadersVariable !== undefined + ? destructuringResponseHeadersVariable.name + : `${responseVariableName}.headers`; + if (headerValue.type === AST_NODE_TYPES.Literal && headerValue.value instanceof RegExp) { + nonStatusAssertions.push( + `assert.ok(${headersReference}.get(${sourceCode.getText(headerName)}).match(${sourceCode.getText(headerValue)}))`, + ); + } else { + nonStatusAssertions.push( + `assert.equal(${headersReference}.get(${sourceCode.getText(headerName)}), ${sourceCode.getText(headerValue)})`, + ); + } + } + } + return { + statusAssertion, + nonStatusAssertions, + }; +} + +// eslint-disable-next-line sonarjs/cognitive-complexity +function getResponseVariableNameToUse( + fetchFunction: TSESTree.CallExpression, + fixtureCallInformation: FixtureCallInformation, + sourceCode: SourceCode, + scopeManager: ScopeManager, + scopeVariablesMap: Map, +) { + // use existing variable assignment if it's already defined + if (fixtureCallInformation.variableAssignment) { + assert.ok( + fixtureCallInformation.variableAssignment.expression.type === AST_NODE_TYPES.AssignmentExpression && + fixtureCallInformation.variableAssignment.expression.left.type === AST_NODE_TYPES.Identifier, + ); + return fixtureCallInformation.variableAssignment.expression.left.name; + } + + // use existing variable declaration if it's already defined + if (fixtureCallInformation.variableDeclaration) { + const firstDeclaration = fixtureCallInformation.variableDeclaration.declarations[0]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (firstDeclaration !== undefined && firstDeclaration.id.type === AST_NODE_TYPES.Identifier) { + return firstDeclaration.id.name; + } + } + + // prepare scope variables for checking if the variable name is already used + const enclosingScopeNode = getEnclosingScopeNode(fixtureCallInformation.rootNode); + assert.ok(enclosingScopeNode); + const scope = scopeManager.acquire(enclosingScopeNode); + assert.ok(scope); + let scopeVariables = scopeVariablesMap.get(scope); + if (!scopeVariables) { + scopeVariables = [...scope.set.keys()]; + scopeVariablesMap.set(scope, scopeVariables); + } + + let responseVariableNameBase: string | undefined; + if (fetchFunction.callee.type === AST_NODE_TYPES.Identifier && fetchFunction.callee.name === 'fetch') { + const [urlArg, initArg] = fetchFunction.arguments; + if (urlArg?.type === AST_NODE_TYPES.Literal || urlArg?.type === AST_NODE_TYPES.TemplateLiteral) { + const urlValue = urlArg.type === AST_NODE_TYPES.Literal ? String(urlArg.value) : sourceCode.getText(urlArg); + + const urlWithoutQuotes = urlValue.replace(/['"`]/gu, ''); + const urlWithoutQuery = urlWithoutQuotes.includes('?') + ? urlWithoutQuotes.slice(0, urlWithoutQuotes.indexOf('?')) + : urlWithoutQuotes; + const parts = urlWithoutQuery.startsWith('${') + ? urlWithoutQuery.split('/').slice(1) + : // eslint-disable-next-line no-magic-numbers + urlWithoutQuery.split('/').slice(3); + + let methodName; + if (initArg?.type === AST_NODE_TYPES.ObjectExpression) { + methodName = /method:\s*['"`](?\w+)['"`]/u.exec(sourceCode.getText(initArg))?.groups?.['method']; + } + methodName ??= 'GET'; + responseVariableNameBase = [...parts.filter((part) => part !== 'tenant'), methodName.toLowerCase()] + .map((part) => part.split(/[-]/u)) + .flat() + .filter((part) => part.trim() !== '' && !/\$\{.*\}/u.test(part)) // remove path parameter placeholders + .map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`) + .join(''); + responseVariableNameBase = `${responseVariableNameBase[0]?.toLowerCase() ?? ''}${responseVariableNameBase.slice(1)}`; + } + } else { + // this should be the case that a reference to utility function is used + const fullUtilityFunctionReference = sourceCode.getText(fetchFunction.callee); + responseVariableNameBase = fullUtilityFunctionReference.split('.').pop(); + } + responseVariableNameBase = + responseVariableNameBase === undefined ? 'response' : `${responseVariableNameBase}Response`; + + let responseVariableCounter = 0; + let responseVariableNameToUse = responseVariableNameBase; + while (scopeVariables.includes(responseVariableNameToUse)) { + responseVariableCounter++; + responseVariableNameToUse = `${responseVariableNameBase}${String(responseVariableCounter)}`; + } + scopeVariables.push(responseVariableNameToUse); + return responseVariableNameToUse; +} + +function isResponseBodyRedefinition(responseBodyReference: TSESTree.MemberExpression): boolean { + const parent = getParent(responseBodyReference); + return parent?.type === AST_NODE_TYPES.VariableDeclarator && parent.id.type === AST_NODE_TYPES.Identifier; +} + +const createRule = ESLintUtils.RuleCreator((name) => getDocumentationUrl(name)); + +const rule: ESLintUtils.RuleModule<'unknownError' | 'preferNativeFetch'> = createRule({ + name: ruleId, + meta: { + type: 'suggestion', + docs: { + description: 'Transform supertest assersions to regular node assertions.', + url: getDocumentationUrl(ruleId), + }, + messages: { + preferNativeFetch: 'Transform supertest assersions to regular node assertions.', + unknownError: 'Unknown error occurred in file "{{fileName}}": {{ error }}.', + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + // eslint-disable-next-line max-lines-per-function + create(context) { + const sourceCode = context.sourceCode; + const parserServices = ESLintUtils.getParserServices(context); + const typeChecker = parserServices.program.getTypeChecker(); + const scopeManager = sourceCode.scopeManager; + assert.ok(scopeManager !== null); + const scopeVariablesMap = new Map(); + + return { + // eslint-disable-next-line max-lines-per-function + 'CallExpression[callee.property.name="expect"]': ( + expectCall: TSESTree.CallExpression, + // eslint-disable-next-line sonarjs/cognitive-complexity + ) => { + try { + if ( + expectCall.callee.type !== AST_NODE_TYPES.MemberExpression || + expectCall.callee.object.type !== AST_NODE_TYPES.CallExpression + ) { + return; + } + + // Check if it's a Promise like object + const calleeObject = expectCall.callee.object; + const calleeObjectTsNode = parserServices.esTreeNodeToTSNodeMap.get(calleeObject); + const calleeObjectType = typeChecker.getTypeAtLocation(calleeObjectTsNode); + const calleeObjectTypeSymbol = calleeObjectType.getSymbol(); + if (!calleeObjectTypeSymbol || calleeObjectTypeSymbol.name !== 'Promise') { + return; + } + const [calleeObjectPromiseType] = typeChecker.getTypeArguments(calleeObjectType as ts.TypeReference); + if (calleeObjectPromiseType === undefined || !isFetchResponse(calleeObjectPromiseType)) { + return; + } + + const indentation = getIndentation(expectCall, sourceCode); + + const fixtureCallInformation = {} as FixtureCallInformation; + const fetchFunction = expectCall.callee.object; + analyzeFixtureCall(fetchFunction, fixtureCallInformation, sourceCode); + + const { + variable: responseVariable, + bodyReferences: responseBodyReferences, + // headersReferences: responseHeadersReferences, + statusReferences: responseStatusReferences, + destructuringBodyVariable: destructuringResponseBodyVariable, + destructuringHeadersVariable: destructuringResponseHeadersVariable, + destructuringStatusVariable: destructuringResponseStatusVariable, + } = analyzeResponseReferences(fixtureCallInformation.variableDeclaration, scopeManager); + + const shouldUsePromiseThen = + isUsedInArrayOrAsArgument(expectCall) || getEnclosingFunction(expectCall)?.async === false; + if (shouldUsePromiseThen) { + const responseVariableNameToUse = 'res'; + const { statusAssertion, nonStatusAssertions } = createResponseAssertions( + fixtureCallInformation, + sourceCode, + responseVariableNameToUse, + destructuringResponseHeadersVariable as Variable | undefined, + ); + const fetchCallText = sourceCode.getText(fetchFunction); + const disableLintComment = '// eslint-disable-next-line @checkdigit/no-promise-instance-method'; + const appendingAssignmentAndAssertionText = [ + ...(statusAssertion !== undefined ? [statusAssertion] : []), + ...nonStatusAssertions, + ].join(`;\n${indentation}`); + const replacementText = fixtureCallInformation.assertions + ? [ + disableLintComment, + `${fetchCallText}.then((${responseVariableNameToUse}) => {`, + appendingAssignmentAndAssertionText === '' ? '' : ` ${appendingAssignmentAndAssertionText};`, + ` return ${responseVariableNameToUse};`, + `})`, + ].join(`\n${indentation}`) + : fetchCallText; + context.report({ + node: fixtureCallInformation.rootNode, + messageId: 'preferNativeFetch', + fix(fixer) { + return fixer.replaceText(fixtureCallInformation.fixtureNode, replacementText); + }, + }); + } else { + const responseVariableNameToUse = getResponseVariableNameToUse( + fetchFunction, + fixtureCallInformation, + sourceCode, + scopeManager, + scopeVariablesMap, + ); + + const isResponseBodyVariableRedefinitionNeeded = + destructuringResponseBodyVariable !== undefined || + fixtureCallInformation.inlineBodyReference !== undefined || + (responseBodyReferences.length > 0 && !responseBodyReferences.some(isResponseBodyRedefinition)); + const redefineResponseBodyVariableName = `${responseVariableNameToUse}Body`; + + const isResponseStatusVariableRedefinitionNeeded = + destructuringResponseStatusVariable !== undefined || + fixtureCallInformation.inlineStatusReference !== undefined; + const redefineResponseStatusVariableName = `${responseVariableNameToUse}Status`; + + const isResponseHeadersVariableRedefinitionNeeded = + (destructuringResponseHeadersVariable !== undefined && + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (destructuringResponseHeadersVariable as TSESTree.ObjectPattern).type === + AST_NODE_TYPES.ObjectPattern) || + fixtureCallInformation.inlineHeadersReference !== undefined; + const redefineResponseHeadersVariableName = `${responseVariableNameToUse}Headers`; + + const isResponseVariableRedefinitionNeeded = + (fixtureCallInformation.variableAssignment === undefined && + responseVariable === undefined && + fixtureCallInformation.assertions !== undefined) || + isResponseBodyVariableRedefinitionNeeded || + isResponseStatusVariableRedefinitionNeeded || + isResponseHeadersVariableRedefinitionNeeded; + + const responseBodyHeadersVariableRedefineLines = isResponseVariableRedefinitionNeeded + ? [ + // eslint-disable-next-line no-nested-ternary + ...(destructuringResponseBodyVariable + ? [ + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + `${fixtureCallInformation.variableDeclaration?.kind ?? 'const'} ${(destructuringResponseBodyVariable as TSESTree.ObjectPattern).type === AST_NODE_TYPES.ObjectPattern ? sourceCode.getText(destructuringResponseBodyVariable as TSESTree.ObjectPattern) : (destructuringResponseBodyVariable as Variable).name} = ${getResponseBodyRetrievalText(responseVariableNameToUse)}`, + ] + : isResponseBodyVariableRedefinitionNeeded + ? [ + `const ${redefineResponseBodyVariableName} = ${getResponseBodyRetrievalText(responseVariableNameToUse)}`, + ] + : []), + // eslint-disable-next-line no-nested-ternary + ...(destructuringResponseStatusVariable + ? [ + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + `${fixtureCallInformation.variableDeclaration?.kind ?? 'const'} ${(destructuringResponseStatusVariable as TSESTree.ObjectPattern).type === AST_NODE_TYPES.ObjectPattern ? sourceCode.getText(destructuringResponseStatusVariable as TSESTree.ObjectPattern) : (destructuringResponseStatusVariable as Variable).name} = ${getResponseStatusRetrievalText(responseVariableNameToUse)}`, + ] + : isResponseStatusVariableRedefinitionNeeded + ? [ + `const ${redefineResponseStatusVariableName} = ${getResponseStatusRetrievalText(responseVariableNameToUse)}`, + ] + : []), + // eslint-disable-next-line no-nested-ternary + ...(destructuringResponseHeadersVariable + ? // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (destructuringResponseHeadersVariable as TSESTree.ObjectPattern).type === + AST_NODE_TYPES.ObjectPattern + ? (destructuringResponseHeadersVariable as TSESTree.ObjectPattern).properties.map((property) => { + assert.ok(property.type === AST_NODE_TYPES.Property); + assert.ok(property.value.type === AST_NODE_TYPES.Identifier); + // eslint-disable-next-line sonarjs/no-nested-template-literals + return `${fixtureCallInformation.variableDeclaration?.kind ?? 'const'} ${property.value.name} = ${getResponseHeadersRetrievalText(responseVariableNameToUse)}.get(${property.key.type === AST_NODE_TYPES.Literal ? sourceCode.getText(property.key) : `'${sourceCode.getText(property.key)}'`})`; + }) + : [ + `${fixtureCallInformation.variableDeclaration?.kind ?? 'const'} ${(destructuringResponseHeadersVariable as Variable).name} = ${getResponseHeadersRetrievalText(responseVariableNameToUse)}`, + ] + : isResponseHeadersVariableRedefinitionNeeded + ? [ + `const ${redefineResponseHeadersVariableName} = ${getResponseHeadersRetrievalText(responseVariableNameToUse)}`, + ] + : []), + ] + : []; + + const { statusAssertion, nonStatusAssertions } = createResponseAssertions( + fixtureCallInformation, + sourceCode, + responseVariableNameToUse, + destructuringResponseHeadersVariable as Variable | undefined, + ); + + // add variable declaration if needed + const fetchCallText = sourceCode.getText(fetchFunction); + const fetchStatementText = !isResponseVariableRedefinitionNeeded + ? fetchCallText + : `${fixtureCallInformation.variableDeclaration?.kind ?? 'const'} ${responseVariableNameToUse} = await ${fetchCallText}`; + + const nodeToReplace = isResponseVariableRedefinitionNeeded + ? fixtureCallInformation.rootNode + : fixtureCallInformation.fixtureNode; + const appendingAssignmentAndAssertionText = [ + '', + ...(statusAssertion !== undefined ? [statusAssertion] : []), + ...responseBodyHeadersVariableRedefineLines, + ...nonStatusAssertions, + ].join(`;\n${indentation}`); + + context.report({ + node: expectCall, + messageId: 'preferNativeFetch', + + *fix(fixer) { + if (fixtureCallInformation.inlineStatementNode) { + const preInlineDeclaration = [ + fetchStatementText, + `${appendingAssignmentAndAssertionText};\n${indentation}`, + ].join(``); + yield fixer.insertTextBefore(fixtureCallInformation.inlineStatementNode, preInlineDeclaration); + } else { + yield fixer.replaceText(nodeToReplace, fetchStatementText); + + const needEndingSemiColon = sourceCode.getText(nodeToReplace).endsWith(';'); + yield fixer.insertTextAfter( + nodeToReplace, + needEndingSemiColon + ? `${appendingAssignmentAndAssertionText};` + : appendingAssignmentAndAssertionText, + ); + } + + // handle response body references + for (const responseBodyReference of responseBodyReferences) { + yield fixer.replaceText( + responseBodyReference, + isResponseBodyVariableRedefinitionNeeded || !isResponseBodyRedefinition(responseBodyReference) + ? redefineResponseBodyVariableName + : getResponseBodyRetrievalText(responseVariableNameToUse), + ); + } + if (fixtureCallInformation.inlineBodyReference) { + yield fixer.replaceText(fixtureCallInformation.inlineBodyReference, redefineResponseBodyVariableName); + } + + // convert response.statusCode to response.status + for (const responseStatusReference of responseStatusReferences) { + if ( + responseStatusReference.property.type === AST_NODE_TYPES.Identifier && + responseStatusReference.property.name === 'statusCode' + ) { + yield fixer.replaceText(responseStatusReference.property, `status`); + } + } + + // handle direct return statement without await, e.g. "return fixture.api.get(...);" + if ( + fixtureCallInformation.rootNode.type === AST_NODE_TYPES.ReturnStatement && + fixtureCallInformation.assertions !== undefined + ) { + yield fixer.insertTextAfter( + fixtureCallInformation.rootNode, + `\n${indentation}return ${responseVariableNameToUse};`, + ); + } + }, + }); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to apply ${ruleId} rule for file "${context.filename}":`, error); + context.report({ + node: expectCall, + messageId: 'unknownError', + data: { + fileName: context.filename, + error: error instanceof Error ? error.toString() : JSON.stringify(error), + }, + }); + } + }, + }; + }, +}); + +export default rule; diff --git a/src/agent/no-fixture.spec.ts b/src/agent/no-fixture.spec.ts new file mode 100644 index 0000000..5737522 --- /dev/null +++ b/src/agent/no-fixture.spec.ts @@ -0,0 +1,358 @@ +// agent/no-fixture.spec.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import createTester from '../ts-tester.test'; +import rule, { ruleId } from './no-fixture'; + +createTester().run(ruleId, rule, { + valid: [], + invalid: [ + { + name: 'concurrent fixture calls inside Promise.all() - without assertions', + code: ` + const responses = await Promise.all([ + fixture.api.put(\`\${BASE_PATH}/key\`).send(keyData), + fixture.api.put(\`\${BASE_PATH}/key\`).send(keyData), + ]); + `, + output: ` + const responses = await Promise.all([ + fetch(\`\${BASE_PATH}/key\`, { + method: 'PUT', + body: JSON.stringify(keyData), + }), + fetch(\`\${BASE_PATH}/key\`, { + method: 'PUT', + body: JSON.stringify(keyData), + }), + ]); + `, + errors: [{ messageId: 'preferNativeFetch' }, { messageId: 'preferNativeFetch' }], + }, + { + name: 'concurrent fixture calls inside Promise.all() - with assertions', + code: `const responses = await Promise.all([ + fixture.api.get(\`\${BASE_PATH}/ping\`).expect(StatusCodes.OK), + fixture.api.get(\`\${BASE_PATH}/ping\`).expect(StatusCodes.OK), + ]);`, + output: `const responses = await Promise.all([ + fetch(\`\${BASE_PATH}/ping\`, { + method: 'GET', + }) + .expect(StatusCodes.OK), + fetch(\`\${BASE_PATH}/ping\`, { + method: 'GET', + }) + .expect(StatusCodes.OK), + ]);`, + errors: [{ messageId: 'preferNativeFetch' }, { messageId: 'preferNativeFetch' }], + }, + { + name: 'assertion with variable declaration', + code: ` + const pingResponse = await fixture.api.get(\`/sample-service/v1/ping\`).expect(StatusCodes.OK); + `, + output: ` + const pingResponse = await fetch(\`\${BASE_PATH}/ping\`, { + method: 'GET', + }) + .expect(StatusCodes.OK); + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'assertion without variable declaration', + code: ` + await fixture.api.get(\`/sample-service/v1/ping\`).expect(StatusCodes.OK); + `, + output: ` + await fetch(\`\${BASE_PATH}/ping\`, { + method: 'GET', + }) + .expect(StatusCodes.OK); + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'assertion without variable declaration - complex status assertion argument', + code: ` + await fixture.api.get(\`/sample-service/v1/ping\`).expect(options.expectedStatusCode ?? StatusCodes.CREATED); + `, + output: ` + await fetch(\`\${BASE_PATH}/ping\`, { + method: 'GET', + }) + .expect(options.expectedStatusCode ?? StatusCodes.CREATED); + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'PUT with request body', + code: ` + await fixture.api.put(\`/sample-service/v2/card/\${uuid()}\`).send(cardCreationData).expect(StatusCodes.BAD_REQUEST); + `, + output: ` + await fetch(\`\${BASE_PATH}/card/\${uuid()}\`, { + method: 'PUT', + body: JSON.stringify(cardCreationData), + }) + .expect(StatusCodes.BAD_REQUEST); + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'PUT with request header', + code: ` + const noFraudResponse = await fixture.api + .post(\`/sample-service/v2/card/\${originalCard.card.cardId}/block/\${encodeURIComponent('BLOCKED NO FRAUD')}\`) + .set(IF_MATCH_HEADER, originalCard.version) + .set('abc', originalCard.name) + .set('x-y-z', '123') + .expect(StatusCodes.NO_CONTENT); + `, + output: ` + const noFraudResponse = await fetch(\`\${BASE_PATH}/card/\${originalCard.card.cardId}/block/\${encodeURIComponent('BLOCKED NO FRAUD')}\`, { + method: 'POST', + headers: { + [IF_MATCH_HEADER]: originalCard.version, + abc: originalCard.name, + 'x-y-z': '123', + }, + }) + .expect(StatusCodes.NO_CONTENT); + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'set request header with "!" (non-null assertion operator)', + code: ` + const noFraudResponse = await fixture.api + .post(\`\${BASE_PATH}/ping\`) + .set(IF_MATCH_HEADER, originalCard.version!) + .set('x-y-z', headers[ETAG]!) + .expect(StatusCodes.NO_CONTENT); + `, + output: ` + const noFraudResponse = await fetch(\`\${BASE_PATH}/ping\`, { + method: 'POST', + headers: { + [IF_MATCH_HEADER]: originalCard.version!, + 'x-y-z': headers[ETAG]!, + }, + }) + .expect(StatusCodes.NO_CONTENT); + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'POST without request header/body', + code: ` + await fixture.api + .post(\`/sample-service/v2/card/\${originalCard.card.cardId}/block/\${encodeURIComponent('BLOCKED NO FRAUD')}\`) + .expect(StatusCodes.NO_CONTENT); + `, + output: ` + await fetch(\`\${BASE_PATH}/card/\${originalCard.card.cardId}/block/\${encodeURIComponent('BLOCKED NO FRAUD')}\`, { + method: 'POST', + }) + .expect(StatusCodes.NO_CONTENT); + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'replace del with DELETE', + code: ` + await fixture.api + .del(\`/sample-service/v2/card/\${originalCard.card.cardId}/block/\${encodeURIComponent('BLOCKED NO FRAUD')}\`) + .expect(StatusCodes.NO_CONTENT); + `, + output: ` + await fetch(\`\${BASE_PATH}/card/\${originalCard.card.cardId}/block/\${encodeURIComponent('BLOCKED NO FRAUD')}\`, { + method: 'DELETE', + }) + .expect(StatusCodes.NO_CONTENT); + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'multiple fixture calls in the same test', + code: ` + async function test() { + await fixture.api.get(\`/sample-service/v1/ping\`).expect(StatusCodes.OK); + const pingGetResponse = await fixture.api.get(\`/sample-service/v1/ping\`).expect(StatusCodes.OK); + await fixture.api.get(\`/sample-service/v1/ping?param=xxx\`).expect(StatusCodes.OK).expect({message:'pong'}); + await fixture.api.get(\`/sample-service/v1/ping\`).expect(StatusCodes.OK); + } + `, + output: ` + async function test() { + await fetch(\`\${BASE_PATH}/ping\`, { + method: 'GET', + }) + .expect(StatusCodes.OK); + const pingGetResponse = await fetch(\`\${BASE_PATH}/ping\`, { + method: 'GET', + }) + .expect(StatusCodes.OK); + await fetch(\`\${BASE_PATH}/ping?param=xxx\`, { + method: 'GET', + }) + .expect(StatusCodes.OK) + .expect({message:'pong'}); + await fetch(\`\${BASE_PATH}/ping\`, { + method: 'GET', + }) + .expect(StatusCodes.OK); + } + `, + errors: [ + { messageId: 'preferNativeFetch' }, + { messageId: 'preferNativeFetch' }, + { messageId: 'preferNativeFetch' }, + { messageId: 'preferNativeFetch' }, + ], + }, + { + name: 'directly return (no await) fixture call', + code: ` + () => { + return fixture.api.get(\`/sample-service/v1/ping\`); + }`, + output: ` + () => { + return fetch(\`\${BASE_PATH}/ping\`, { + method: 'GET', + }); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'directly return (no await) fixture call with assertion', + code: ` + async () => { + return fixture.api.get(\`/sample-service/v1/ping\`).expect(StatusCodes.OK); + }`, + output: ` + async () => { + return fetch(\`\${BASE_PATH}/ping\`, { + method: 'GET', + }) + .expect(StatusCodes.OK); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'directly return (no await) fixture call with body/headers', + code: ` + () => { + return fixture.api.put(\`/sample-service/v2/card/\${uuid()}\`) + .set(IF_MATCH_HEADER, originalCard.version) + .send({}); + }`, + output: ` + () => { + return fetch(\`\${BASE_PATH}/card/\${uuid()}\`, { + method: 'PUT', + body: JSON.stringify({}), + headers: { + [IF_MATCH_HEADER]: originalCard.version, + }, + }); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'work with response status literal (e.g. 200 instead of StatusCoodes.OK) as well', + code: ` + await fixture.api.get(\`/sample-service/v2/ping\`).expect(200); + `, + output: ` + await fetch(\`\${BASE_PATH}/ping\`, { + method: 'GET', + }) + .expect(200); + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'assert response body against function call\'s return value ".expect(validateBody(response))"', + code: ` + await fixture.api.get(\`/sample-service/v2/ping\`).expect(200).expect(validateBody(createdOn)); + `, + output: ` + await fetch(\`\${BASE_PATH}/ping\`, { + method: 'GET', + }) + .expect(200) + .expect(validateBody(createdOn)); + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'inline access to response body should be extracted to a variable', + code: ` + export async function validatePin( + fixture, + ) { + const publicKeyGetResponse = (await fixture.api.get(\`\${BASE_PATH}/public-key\`).expect(StatusCodes.OK)).body.publicKey; + } + `, + output: ` + export async function validatePin( + fixture, + ) { + const publicKeyGetResponse = (await fetch(\`\${BASE_PATH}/public-key\`, { + method: 'GET', + }) + .expect(StatusCodes.OK)).body.publicKey; + } + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'in arrow function without concurrent promises', + code: ` + const delayedCardCreationPromise = new Promise((delayedExecution) => { + setTimeout(() => { + delayedExecution(fixture.api.put(\`\${BASE_PATH}/card/\${cardId}\`).send(otherTestCard)); + }, 600); + }); + `, + output: ` + const delayedCardCreationPromise = new Promise((delayedExecution) => { + setTimeout(() => { + delayedExecution(fetch(\`\${BASE_PATH}/card/\${cardId}\`, { + method: 'PUT', + body: JSON.stringify(otherTestCard), + })); + }, 600); + }); + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'support setting headers using object literal', + code: `function doSomething() { + return fixture.api + .get(\`\${BASE_PATH}/ping\`) + .set({ + ...(options?.createdOn ? { [CREATED_ON_HEADER]: options.createdOn } : {}), + }); + }`, + output: `function doSomething() { + return fetch(\`\${BASE_PATH}/ping\`, { + method: 'GET', + headers: { + ...(options?.createdOn ? { [CREATED_ON_HEADER]: options.createdOn } : {}), + }, + }); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + ], +}); diff --git a/src/agent/no-fixture.ts b/src/agent/no-fixture.ts new file mode 100644 index 0000000..6565f87 --- /dev/null +++ b/src/agent/no-fixture.ts @@ -0,0 +1,203 @@ +// agent/no-fixture.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { strict as assert } from 'node:assert'; + +import { AST_NODE_TYPES, ESLintUtils, TSESTree } from '@typescript-eslint/utils'; +import type { SourceCode } from '@typescript-eslint/utils/ts-eslint'; + +import { getParent } from '../library/ts-tree'; +import getDocumentationUrl from '../get-documentation-url'; +import { getIndentation } from '../library/format'; +import { isValidPropertyName } from '../library/variable'; +import { replaceEndpointUrlPrefixWithBasePath } from './url'; + +export const ruleId = 'no-fixture'; + +interface FixtureCallInformation { + fixtureNode: TSESTree.CallExpression; + requestBody?: TSESTree.Expression; + requestHeaders?: { name: TSESTree.Expression; value: TSESTree.Expression }[]; + requestHeadersObjectLiteral?: TSESTree.ObjectExpression; + statusAssertion?: TSESTree.CallExpressionArgument[]; + nonStatusAssertions?: TSESTree.CallExpressionArgument[][]; +} + +function isStatusAssertion(expectArguments: TSESTree.CallExpressionArgument[]) { + if (expectArguments.length === 1) { + const [maybeStatusAssertion] = expectArguments; + assert.ok(maybeStatusAssertion); + if ( + (maybeStatusAssertion.type === AST_NODE_TYPES.MemberExpression && + maybeStatusAssertion.object.type === AST_NODE_TYPES.Identifier && + maybeStatusAssertion.object.name === 'StatusCodes') || + (maybeStatusAssertion.type === AST_NODE_TYPES.Literal && typeof maybeStatusAssertion.value === 'number') + ) { + return true; + } + } + return false; +} + +// recursively analyze the fixture/supertest call chain to collect information of request/response +function analyzeFixtureCall(call: TSESTree.CallExpression, results: FixtureCallInformation, sourceCode: SourceCode) { + sourceCode.getText(call); + results.fixtureNode = call; + + let nextCall; + const parent = getParent(call); + assert.ok(parent, 'parent should exist for fixture/supertest call node'); + + if (parent.type === AST_NODE_TYPES.MemberExpression && parent.property.type === AST_NODE_TYPES.Identifier) { + if (parent.property.name === 'expect') { + // supertest assertions + const assertionCall = getParent(parent); + assert.ok(assertionCall && assertionCall.type === AST_NODE_TYPES.CallExpression); + if (isStatusAssertion(assertionCall.arguments)) { + results.statusAssertion = assertionCall.arguments; + } else { + results.nonStatusAssertions = [...(results.nonStatusAssertions ?? []), assertionCall.arguments]; + } + nextCall = assertionCall; + } else if (parent.property.name === 'send') { + // request body + const sendRequestBodyCall = getParent(parent); + assert.ok(sendRequestBodyCall && sendRequestBodyCall.type === AST_NODE_TYPES.CallExpression); + results.requestBody = sendRequestBodyCall.arguments[0] as TSESTree.Expression; + nextCall = sendRequestBodyCall; + } else if (parent.property.name === 'set') { + // request headers + const setRequestHeaderCall = getParent(parent); + assert.ok(setRequestHeaderCall && setRequestHeaderCall.type === AST_NODE_TYPES.CallExpression); + const [arg1, arg2] = setRequestHeaderCall.arguments as [TSESTree.Expression, TSESTree.Expression]; + if (arg1.type === AST_NODE_TYPES.ObjectExpression) { + results.requestHeadersObjectLiteral = arg1; + } else { + results.requestHeaders = [...(results.requestHeaders ?? []), { name: arg1, value: arg2 }]; + } + nextCall = setRequestHeaderCall; + } + } + if (nextCall) { + analyzeFixtureCall(nextCall, results, sourceCode); + } +} + +function getExpectAssertion(expectArguments: TSESTree.CallExpressionArgument[], sourceCode: SourceCode) { + return `expect(${expectArguments.map((arg) => sourceCode.getText(arg)).join(', ')})`; +} + +const createRule = ESLintUtils.RuleCreator((name) => getDocumentationUrl(name)); + +const rule: ESLintUtils.RuleModule<'unknownError' | 'preferNativeFetch'> = createRule({ + name: ruleId, + meta: { + type: 'suggestion', + docs: { + description: 'Prefer native fetch API over customized fixture API.', + url: getDocumentationUrl(ruleId), + }, + messages: { + preferNativeFetch: 'Prefer native fetch API over customized fixture API.', + unknownError: 'Unknown error occurred in file "{{fileName}}": {{ error }}.', + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + const scopeManager = sourceCode.scopeManager; + assert.ok(scopeManager !== null); + + return { + 'CallExpression[callee.object.object.name="fixture"][callee.object.property.name="api"]': ( + fixtureCall: TSESTree.CallExpression, + ) => { + try { + const fixtureFunction = fixtureCall.callee; // e.g. fixture.api.get + assert.ok(fixtureFunction.type === AST_NODE_TYPES.MemberExpression); + const indentation = getIndentation(fixtureCall, sourceCode); + + const [urlArgumentNode] = fixtureCall.arguments; // e.g. `/sample-service/v1/ping` + assert.ok(urlArgumentNode !== undefined); + + const fixtureCallInformation = {} as FixtureCallInformation; + analyzeFixtureCall(fixtureCall, fixtureCallInformation, sourceCode); + + // convert url from `/sample-service/v1/ping` to `${BASE_PATH}/ping` + const originalUrlArgumentText = sourceCode.getText(urlArgumentNode); + const fetchUrlArgumentText = replaceEndpointUrlPrefixWithBasePath(originalUrlArgumentText); + + // fetch request argument + const methodNode = fixtureFunction.property; // get/put/etc. + assert.ok(methodNode.type === AST_NODE_TYPES.Identifier); + const methodName = methodNode.name.toUpperCase(); + const methodNameToUse = methodName === 'DEL' ? 'DELETE' : methodName; + + const fetchRequestArgumentLines = [ + '{', + ` method: '${methodNameToUse}',`, + ...(fixtureCallInformation.requestBody + ? [` body: JSON.stringify(${sourceCode.getText(fixtureCallInformation.requestBody)}),`] + : []), + // eslint-disable-next-line no-nested-ternary + ...(fixtureCallInformation.requestHeadersObjectLiteral + ? [` headers: ${sourceCode.getText(fixtureCallInformation.requestHeadersObjectLiteral)},`] + : fixtureCallInformation.requestHeaders + ? [ + ` headers: {`, + ...fixtureCallInformation.requestHeaders.map( + ({ name, value }) => + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions, no-nested-ternary, sonarjs/no-nested-template-literals + ` ${name.type === AST_NODE_TYPES.Literal ? (isValidPropertyName(name.value) ? name.value : `'${name.value}'`) : `[${sourceCode.getText(name)}]`}: ${sourceCode.getText(value)},`, + ), + ` },`, + ] + : []), + '}', + ].join(`\n${indentation}`); + + const fetchCallText = `fetch(${fetchUrlArgumentText}, ${fetchRequestArgumentLines})`; + const fetchStatementText = [ + fetchCallText, + ...(fixtureCallInformation.statusAssertion === undefined + ? [] + : [getExpectAssertion(fixtureCallInformation.statusAssertion, sourceCode)]), + ...(fixtureCallInformation.nonStatusAssertions === undefined + ? [] + : fixtureCallInformation.nonStatusAssertions.map((assertion) => + getExpectAssertion(assertion, sourceCode), + )), + ].join(`\n${indentation}.`); + + context.report({ + node: fixtureCallInformation.fixtureNode, + messageId: 'preferNativeFetch', + fix(fixer) { + return fixer.replaceText(fixtureCallInformation.fixtureNode, fetchStatementText); + }, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to apply ${ruleId} rule for file "${context.filename}":`, error); + context.report({ + node: fixtureCall, + messageId: 'unknownError', + data: { + fileName: context.filename, + error: error instanceof Error ? error.toString() : JSON.stringify(error), + }, + }); + } + }, + }; + }, +}); + +export default rule; diff --git a/src/agent/no-mapped-response.spec.ts b/src/agent/no-mapped-response.spec.ts new file mode 100644 index 0000000..db413dc --- /dev/null +++ b/src/agent/no-mapped-response.spec.ts @@ -0,0 +1,54 @@ +// agent/no-mapped-response-type.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import createTester from '../ts-tester.test'; +import rule, { ruleId } from './no-mapped-response'; + +createTester().run(ruleId, rule, { + valid: [], + invalid: [ + { + name: 'import statement', + code: `import type { MappedResponse } from '../../../services';`, + output: `import type { FetchResponse } from '../../../services';`, + errors: [{ messageId: 'replaceFullResponseWithFetchResponse' }], + }, + { + name: 'import statement with multiple imports', + code: `import type { apiV1, MappedResponse, xxx } from '../../../services';`, + output: `import type { apiV1, FetchResponse, xxx } from '../../../services';`, + errors: [{ messageId: 'replaceFullResponseWithFetchResponse' }], + }, + { + name: 'import statement mixing type and value imports', + code: `import { type apiV1, type MappedResponse, xxx } from '../../../services';`, + output: `import { type apiV1, type FetchResponse, xxx } from '../../../services';`, + errors: [{ messageId: 'replaceFullResponseWithFetchResponse' }], + }, + { + name: 'function return type', + code: ` + export async function getSensitiveInformation(): Promise> { + return; + } + `, + output: ` + export async function getSensitiveInformation(): Promise> { + return; + } + `, + errors: [{ messageId: 'replaceFullResponseWithFetchResponse' }], + }, + { + name: 'type casting', + code: `const fullResponse = response as MappedResponse;`, + output: `const fullResponse = response as FetchResponse;`, + errors: [{ messageId: 'replaceFullResponseWithFetchResponse' }], + }, + ], +}); diff --git a/src/agent/no-mapped-response.ts b/src/agent/no-mapped-response.ts new file mode 100644 index 0000000..9bec6ab --- /dev/null +++ b/src/agent/no-mapped-response.ts @@ -0,0 +1,84 @@ +// agent/no-mapped-response-type.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { ESLintUtils, TSESTree } from '@typescript-eslint/utils'; +import getDocumentationUrl from '../get-documentation-url'; + +export const ruleId = 'no-mapped-response'; + +const createRule = ESLintUtils.RuleCreator((name) => getDocumentationUrl(name)); + +const rule: ESLintUtils.RuleModule<'unknownError' | 'replaceFullResponseWithFetchResponse'> = createRule({ + name: ruleId, + meta: { + type: 'suggestion', + docs: { + description: 'Replace the usage of MappedResponse type with FetchResponse.', + }, + messages: { + replaceFullResponseWithFetchResponse: 'Replace the usage of FullResponse type with FetchResponse.', + unknownError: 'Unknown error occurred in file "{{fileName}}": {{ error }}.', + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + + return { + 'TSTypeReference[typeName.name="MappedResponse"]': (typeReference: TSESTree.TSTypeReference) => { + try { + context.report({ + messageId: 'replaceFullResponseWithFetchResponse', + node: typeReference, + fix(fixer) { + const typeParams = sourceCode.getText(typeReference.typeArguments); + return fixer.replaceText(typeReference, `FetchResponse${typeParams || ''}`); + }, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to apply ${ruleId} rule for file "${context.filename}":`, error); + context.report({ + node: typeReference, + messageId: 'unknownError', + data: { + fileName: context.filename, + error: error instanceof Error ? error.toString() : JSON.stringify(error), + }, + }); + } + }, + 'ImportSpecifier[imported.name="MappedResponse"]': (importSpecifier: TSESTree.ImportSpecifier) => { + try { + context.report({ + messageId: 'replaceFullResponseWithFetchResponse', + node: importSpecifier.imported, + fix(fixer) { + return fixer.replaceText(importSpecifier.imported, 'FetchResponse'); + }, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to apply ${ruleId} rule for file "${context.filename}":`, error); + context.report({ + node: importSpecifier.imported, + messageId: 'unknownError', + data: { + fileName: context.filename, + error: error instanceof Error ? error.toString() : JSON.stringify(error), + }, + }); + } + }, + }; + }, +}); + +export default rule; diff --git a/src/agent/no-service-wrapper.spec.ts b/src/agent/no-service-wrapper.spec.ts new file mode 100644 index 0000000..df6bf02 --- /dev/null +++ b/src/agent/no-service-wrapper.spec.ts @@ -0,0 +1,378 @@ +// agent/no-service-wrapper.spec.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import createTester from '../ts-tester.test'; +import rule, { ruleId } from './no-service-wrapper'; + +createTester().run(ruleId, rule, { + valid: [ + { + name: 'none service wrapper call will not trigger an error', + code: `response.headers.get('foo');`, + }, + { + name: 'no change if already converted to fetch', + code: `fetch(\`https://ping.checkdigit/ping/v1/ping\`);`, + }, + ], + invalid: [ + { + name: 'service wrapper passed in as a function argument with type as Endpoint', + code: ` + async function getKey(pingService: Endpoint) { + await pingService.get(\`\${PING_BASE_PATH}/key/\${keyId}\`, { + resolveWithFullResponse: true, + }); + } + `, + output: ` + async function getKey(pingService: Endpoint) { + await fetch(\`\${PING_BASE_PATH}/key/\${keyId}\`, { + method: 'GET', + }); + } + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'service wrapper passed in as a function argument with type as ResolvedService', + code: ` + async function getKey( + pingService: ResolvedService, + request: InboundContext + ) { + await pingService(request).get(\`\${PING_BASE_PATH}/key/\${keyId}\`, { + resolveWithFullResponse: true, + }); + } + `, + output: ` + async function getKey( + pingService: ResolvedService, + request: InboundContext + ) { + await fetch(\`\${PING_BASE_PATH}/key/\${keyId}\`, { + method: 'GET', + }); + } + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'service configuration passed in as a argument with type as Configuration', + code: ` + async function getKey( + config: Configuration, + ) { + await config.service.ping(EMPTY_CONTEXT).get(\`\${PING_BASE_PATH}/key/\${keyId}\`, { + resolveWithFullResponse: true, + }); + } + `, + output: ` + async function getKey( + config: Configuration, + ) { + await fetch(\`\${PING_BASE_PATH}/key/\${keyId}\`, { + method: 'GET', + }); + } + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'fixture passed in as a argument', + code: ` + async function getKey( + fixture: Fixture, + ) { + await fixture.config.service.ping(EMPTY_CONTEXT).get(\`\${PING_BASE_PATH}/key/\${keyId}\`, { + resolveWithFullResponse: true, + }); + } + `, + output: ` + async function getKey( + fixture: Fixture, + ) { + await fetch(\`\${PING_BASE_PATH}/key/\${keyId}\`, { + method: 'GET', + }); + } + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'url declared as a variable', + code: `function doSomething() { + const url = \`\${PING_BASE_PATH}/key/\${keyId}\`; + await pingService.get(url, { + resolveWithFullResponse: true, + }); + }`, + output: `function doSomething() { + const url = \`\${PING_BASE_PATH}/key/\${keyId}\`; + await fetch(url, { + method: 'GET', + }); + }`, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'handle request with headers', + code: ` + async function getKey( + fixture: Fixture, + ) { + await fixture.config.service.ping(EMPTY_CONTEXT).head(\`\${PING_BASE_PATH}/key/\${keyId}\`, { + resolveWithFullResponse: true, + headers: { + 'Content-Type': 'application/json', + }, + }); + } + `, + output: ` + async function getKey( + fixture: Fixture, + ) { + await fetch(\`\${PING_BASE_PATH}/key/\${keyId}\`, { + method: 'HEAD', + headers: { + 'Content-Type': 'application/json', + }, + }); + } + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'handle request with body', + code: ` + async function getKey( + fixture: Fixture, + ) { + const pingService = fixture.config.service.ping(EMPTY_CONTEXT); + const response = await pingService.put(\`\${PING_BASE_PATH}/key/\${keyId}\`, {data:'hi'}, { + resolveWithFullResponse: true, + }); + } + `, + output: ` + async function getKey( + fixture: Fixture, + ) { + const pingService = fixture.config.service.ping(EMPTY_CONTEXT); + const response = await fetch(\`\${PING_BASE_PATH}/key/\${keyId}\`, { + method: 'PUT', + body: JSON.stringify({data:'hi'}), + }); + } + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'handle PUT request with undefined body', + code: ` + async function getKey( + fixture: Fixture, + ) { + const pingService = fixture.config.service.ping(EMPTY_CONTEXT); + const response = await pingService.put(\`\${PING_BASE_PATH}/key/\${keyId}\`, undefined, { + resolveWithFullResponse: true, + }); + } + `, + output: ` + async function getKey( + fixture: Fixture, + ) { + const pingService = fixture.config.service.ping(EMPTY_CONTEXT); + const response = await fetch(\`\${PING_BASE_PATH}/key/\${keyId}\`, { + method: 'PUT', + }); + } + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'handle request with both body and headers', + code: ` + async function getKey( + fixture: Fixture, + keyRequest: ping.KeyRequest, + ) { + const pingService = fixture.config.service.ping(EMPTY_CONTEXT); + const response = await pingService.post(\`\${PING_BASE_PATH}/key/\${keyId}\`, keyRequest, { + resolveWithFullResponse: true, + headers: { + etag: '123', + }, + }); + } + `, + output: ` + async function getKey( + fixture: Fixture, + keyRequest: ping.KeyRequest, + ) { + const pingService = fixture.config.service.ping(EMPTY_CONTEXT); + const response = await fetch(\`\${PING_BASE_PATH}/key/\${keyId}\`, { + method: 'POST', + headers: { + etag: '123', + }, + body: JSON.stringify(keyRequest), + }); + } + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'replace del method as DELETE', + code: ` + const pingService = fixture.config.service.ping(EMPTY_CONTEXT); + const response = await pingService.del(\`\${PING_BASE_PATH}/key/\${keyId}\`, { + resolveWithFullResponse: true, + }); + `, + output: ` + const pingService = fixture.config.service.ping(EMPTY_CONTEXT); + const response = await fetch(\`\${PING_BASE_PATH}/key/\${keyId}\`, { + method: 'DELETE', + }); + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'initiate and call serve-runtime service in the same function', + code: ` + import type { Configuration, InboundContext } from '@checkdigit/serve-runtime'; + import type { pingV1 as ping } from '../services'; + + export async function createKey( + config: Configuration, + inboundContext: InboundContext, + keyRequest: ping.KeyRequest, + ): Promise { + const pingService = config.service.ping(inboundContext); + const newKeyResponse = await pingService.put( + \`\${PING_BASE_PATH}/key/\${keyId}\`, + keyRequest, + { + resolveWithFullResponse: true, + }, + ); + if (newKeyResponse.statusCode !== StatusCodes.OK) { + throw new Error('failed'); + } + return newKeyResponse.body; + } + `, + output: ` + import type { Configuration, InboundContext } from '@checkdigit/serve-runtime'; + import type { pingV1 as ping } from '../services'; + + export async function createKey( + config: Configuration, + inboundContext: InboundContext, + keyRequest: ping.KeyRequest, + ): Promise { + const pingService = config.service.ping(inboundContext); + const newKeyResponse = await fetch(\`\${PING_BASE_PATH}/key/\${keyId}\`, { + method: 'PUT', + body: JSON.stringify(keyRequest), + }); + if (newKeyResponse.statusCode !== StatusCodes.OK) { + throw new Error('failed'); + } + return newKeyResponse.body; + } + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'convert url to add domain', + code: ` + await pingService.get(\`/ping/v1/key/\${keyId}\`, { + resolveWithFullResponse: true, + }); + `, + output: ` + await fetch(\`https://ping.checkdigit/ping/v1/key/\${keyId}\`, { + method: 'GET', + }); + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'works with string literal url as well', + code: ` + await pingService.get('/ping/v1/ping', { + resolveWithFullResponse: true, + }); + `, + output: ` + await fetch('https://ping.checkdigit/ping/v1/ping', { + method: 'GET', + }); + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'do not convert url containing BASE_PATH constant for the main service', + code: ` + await service.get(\`\${BASE_PATH}/key/\${keyId}\`, { + resolveWithFullResponse: true, + }); + `, + output: ` + await fetch(\`\${BASE_PATH}/key/\${keyId}\`, { + method: 'GET', + }); + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'do not convert url containing BASE_PATH like constant for the dependent service', + code: ` + await pingService.get(\`\${PING_BASE_PATH}/key/\${keyId}\`, { + resolveWithFullResponse: true, + }); + `, + output: ` + await fetch(\`\${PING_BASE_PATH}/key/\${keyId}\`, { + method: 'GET', + }); + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + { + name: 'handle multi-line url string literal', + code: ` + await pingService.get(\`/message/v1/picked-request?cardId=\${cardIds.toString()}fromDate={encodeURIComponent( + fromDate, + )}toDate=\${encodeURIComponent( + toDate, + )}fields=ADVICE_RESPONSE,CATEGORIZATION,CREATED_ON,MATCHED_MESSAGE_ID,SETTLEMENT_AMOUNT,MESSAGE_ID,RECEIVED_DATE_TIME\`, { + resolveWithFullResponse: true, + }); + `, + output: ` + await fetch(\`https://message.checkdigit/message/v1/picked-request?cardId=\${cardIds.toString()}fromDate={encodeURIComponent( + fromDate, + )}toDate=\${encodeURIComponent( + toDate, + )}fields=ADVICE_RESPONSE,CATEGORIZATION,CREATED_ON,MATCHED_MESSAGE_ID,SETTLEMENT_AMOUNT,MESSAGE_ID,RECEIVED_DATE_TIME\`, { + method: 'GET', + }); + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + ], +}); diff --git a/src/agent/no-service-wrapper.ts b/src/agent/no-service-wrapper.ts new file mode 100644 index 0000000..b2c731a --- /dev/null +++ b/src/agent/no-service-wrapper.ts @@ -0,0 +1,241 @@ +// agent/no-service-wrapper.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { strict as assert } from 'node:assert'; + +import { DefinitionType, type Scope } from '@typescript-eslint/scope-manager'; +import { AST_NODE_TYPES, ESLintUtils, TSESTree } from '@typescript-eslint/utils'; + +import getDocumentationUrl from '../get-documentation-url'; +import { getEnclosingScopeNode } from '../library/ts-tree'; +import { getIndentation } from '../library/format'; +import { isServiceApiCallUrl, replaceEndpointUrlPrefixWithDomain } from './url'; + +export const ruleId = 'no-service-wrapper'; + +const createRule = ESLintUtils.RuleCreator((name) => getDocumentationUrl(name)); + +const rule: ESLintUtils.RuleModule<'unknownError' | 'preferNativeFetch' | 'invalidOptions'> = createRule({ + name: ruleId, + meta: { + type: 'suggestion', + docs: { + description: 'Prefer native fetch over customized service wrapper.', + }, + messages: { + preferNativeFetch: 'Prefer native fetch over customized service wrapper.', + invalidOptions: + '"options" argument should be provided with "resolveWithFullResponse" property set as "true". Otherwise, it indicates that the response body will be obtained without status code assertion which could result in unexpected issue. Please manually convert the usage of customized service wrapper call to native fetch.', + unknownError: + 'Unknown error occurred in file "{{fileName}}": {{ error }}. Please manually convert the usage of customized service wrapper call to native fetch.', + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + const scopeManager = sourceCode.scopeManager; + const parserServices = ESLintUtils.getParserServices(context); + const typeChecker = parserServices.program.getTypeChecker(); + + function isUrlArgumentValid(urlArgument: TSESTree.Node | undefined, scope: Scope) { + if ( + (urlArgument?.type === AST_NODE_TYPES.Literal && typeof urlArgument.value === 'string') || + urlArgument?.type === AST_NODE_TYPES.TemplateLiteral + ) { + const urlText = sourceCode.getText(urlArgument); + return isServiceApiCallUrl(urlText); + } + + if (urlArgument?.type === AST_NODE_TYPES.Identifier) { + const foundVariable = scope.variables.find((variable) => variable.name === urlArgument.name); + if (foundVariable) { + const variableDefinition = foundVariable.defs.find((def) => def.type === DefinitionType.Variable); + if (variableDefinition !== undefined) { + const variableDefinitionNode = variableDefinition.node; + assert.ok(variableDefinitionNode.init, 'Variable definition node has no init property'); + return isUrlArgumentValid(variableDefinitionNode.init, scope); + } + return true; + } + } + + return false; + } + + function getType(identifier: TSESTree.Identifier) { + const variable = parserServices.esTreeNodeToTSNodeMap.get(identifier); + const variableType = typeChecker.getTypeAtLocation(variable); + return typeChecker.typeToString(variableType); + } + + function isServiceLikeName(name: string) { + return /.*[Ss]ervice$/u.test(name); + } + + function isCalleeServiceWrapper(serviceCall: TSESTree.CallExpression) { + const callee = serviceCall.callee; + if (callee.type !== AST_NODE_TYPES.MemberExpression) { + return false; + } + + const endpoint = callee.object; + if (endpoint.type === AST_NODE_TYPES.Identifier) { + return getType(endpoint) === 'Endpoint' || isServiceLikeName(endpoint.name); + } + if (endpoint.type !== AST_NODE_TYPES.CallExpression) { + return false; + } + + const [contextArgument] = endpoint.arguments; + if (contextArgument?.type !== AST_NODE_TYPES.Identifier) { + return false; + } + if (contextArgument.name !== 'EMPTY_CONTEXT' && getType(contextArgument) !== 'InboundContext') { + return false; + } + const service = endpoint.callee; + if (service.type === AST_NODE_TYPES.Identifier) { + return getType(service) === 'ResolvedService'; + } + + if (service.type !== AST_NODE_TYPES.MemberExpression) { + return false; + } + const services = service.object; + if (services.type === AST_NODE_TYPES.Identifier) { + return getType(services) === 'ResolvedServices'; + } + + if (services.type !== AST_NODE_TYPES.MemberExpression) { + return false; + } + const configuration = services.object; + if (configuration.type === AST_NODE_TYPES.Identifier) { + return ['Configuration', 'Configuration'].includes(getType(configuration)); + } + + // following applies only to test code (fixture) + if (configuration.type !== AST_NODE_TYPES.MemberExpression) { + return false; + } + const fixture = configuration.object; + if (fixture.type === AST_NODE_TYPES.Identifier) { + return fixture.name === 'fixture' || getType(fixture) === 'Fixture'; + } + + return false; + } + + return { + 'CallExpression[callee.property.name=/^(head|get|put|post|del|patch)$/]': ( + serviceCall: TSESTree.CallExpression, + ) => { + try { + if (!isCalleeServiceWrapper(serviceCall)) { + return; + } + + const enclosingScopeNode = getEnclosingScopeNode(serviceCall); + assert.ok(enclosingScopeNode, 'enclosingScopeNode is undefined'); + const scope = scopeManager?.acquire(enclosingScopeNode); + assert.ok(scope, 'scope is undefined'); + const urlArgument = serviceCall.arguments[0]; + if (!isUrlArgumentValid(urlArgument, scope)) { + return; + } + + assert.ok(serviceCall.callee.type === AST_NODE_TYPES.MemberExpression); + assert.ok(serviceCall.callee.property.type === AST_NODE_TYPES.Identifier); + + // method + const method = serviceCall.callee.property.name; + + // body + let requestBodyProperty = ['put', 'post', 'options'].includes(method) ? serviceCall.arguments[1] : undefined; + if ( + requestBodyProperty !== undefined && + requestBodyProperty.type === AST_NODE_TYPES.Identifier && + requestBodyProperty.name === 'undefined' + ) { + requestBodyProperty = undefined; + } + // options + const optionsArgument = ['get', 'head', 'del'].includes(method) + ? serviceCall.arguments[1] + : serviceCall.arguments[2]; + if (optionsArgument === undefined || optionsArgument.type !== AST_NODE_TYPES.ObjectExpression) { + context.report({ + node: serviceCall, + messageId: 'invalidOptions', + }); + return; + } + const resolveWithFullResponseProperty = optionsArgument.properties.find( + (property) => + property.type === AST_NODE_TYPES.Property && + property.key.type === AST_NODE_TYPES.Identifier && + property.key.name === 'resolveWithFullResponse', + ); + if ( + resolveWithFullResponseProperty?.type !== AST_NODE_TYPES.Property || + resolveWithFullResponseProperty.value.type !== AST_NODE_TYPES.Literal || + resolveWithFullResponseProperty.value.value !== true + ) { + context.report({ + node: optionsArgument, + messageId: 'invalidOptions', + }); + return; + } + + // headers + const requestHeadersProperty = optionsArgument.properties.find( + (property) => + property.type === AST_NODE_TYPES.Property && + property.key.type === AST_NODE_TYPES.Identifier && + property.key.name === 'headers', + ); + + context.report({ + messageId: 'preferNativeFetch', + node: serviceCall, + fix(fixer) { + const url = sourceCode.getText(urlArgument); + const replacedUrl = replaceEndpointUrlPrefixWithDomain(url); + const indentation = getIndentation(serviceCall, sourceCode); + + const fetchText = [ + `fetch(${replacedUrl}, {`, + ` method: '${method.toLowerCase() === 'del' ? 'DELETE' : method.toUpperCase()}',`, + ...(requestHeadersProperty ? [` ${sourceCode.getText(requestHeadersProperty)},`] : []), + ...(requestBodyProperty ? [` body: JSON.stringify(${sourceCode.getText(requestBodyProperty)}),`] : []), + '})', + ].join(`\n${indentation}`); + return fixer.replaceText(serviceCall, fetchText); + }, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to apply ${ruleId} rule for file "${context.filename}":`, error); + context.report({ + node: serviceCall, + messageId: 'unknownError', + data: { + fileName: context.filename, + error: error instanceof Error ? error.toString() : JSON.stringify(error), + }, + }); + } + }, + }; + }, +}); + +export default rule; diff --git a/src/agent/no-status-code.spec.ts b/src/agent/no-status-code.spec.ts new file mode 100644 index 0000000..3369bee --- /dev/null +++ b/src/agent/no-status-code.spec.ts @@ -0,0 +1,36 @@ +// agent/no-status-code.spec.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import createTester from '../ts-tester.test'; +import rule, { ruleId } from './no-status-code'; + +createTester().run(ruleId, rule, { + valid: [ + { + name: 'no change if no "status" property is found in the response type', + code: ` + const response = {statusCode: 200}; + const status = response.statusCode; + `, + }, + ], + invalid: [ + { + name: 'replace statusCode with status', + code: ` + const response = await fetch(\`https://ping.checkdigit/ping/v1/key/\${keyId}\`); + const status = response.statusCode; + `, + output: ` + const response = await fetch(\`https://ping.checkdigit/ping/v1/key/\${keyId}\`); + const status = response.status; + `, + errors: [{ messageId: 'replaceStatusCode' }], + }, + ], +}); diff --git a/src/agent/no-status-code.ts b/src/agent/no-status-code.ts new file mode 100644 index 0000000..71d68c8 --- /dev/null +++ b/src/agent/no-status-code.ts @@ -0,0 +1,69 @@ +// agent/no-status-code.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { ESLintUtils, TSESTree } from '@typescript-eslint/utils'; + +import getDocumentationUrl from '../get-documentation-url'; +import { isFetchResponse } from './fetch'; + +export const ruleId = 'no-status-code'; + +const createRule = ESLintUtils.RuleCreator((name) => getDocumentationUrl(name)); + +const rule: ESLintUtils.RuleModule<'unknownError' | 'replaceStatusCode'> = createRule({ + name: ruleId, + meta: { + type: 'suggestion', + docs: { + description: 'Access the status code property of the fetch Response using "status" instead of "statusCode".', + }, + messages: { + replaceStatusCode: 'Replace "statusCode" with "status".', + unknownError: 'Unknown error occurred in file "{{fileName}}": {{ error }}.', + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + const parserServices = ESLintUtils.getParserServices(context); + const typeChecker = parserServices.program.getTypeChecker(); + + return { + 'MemberExpression[property.name="statusCode"]': (responseStatusCode: TSESTree.MemberExpression) => { + try { + const responseNode = parserServices.esTreeNodeToTSNodeMap.get(responseStatusCode.object); + const responseType = typeChecker.getTypeAtLocation(responseNode); + + if (isFetchResponse(responseType)) { + context.report({ + messageId: 'replaceStatusCode', + node: responseStatusCode.property, + fix(fixer) { + return fixer.replaceText(responseStatusCode.property, 'status'); + }, + }); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to apply ${ruleId} rule for file "${context.filename}":`, error); + context.report({ + node: responseStatusCode, + messageId: 'unknownError', + data: { + fileName: context.filename, + error: error instanceof Error ? error.toString() : JSON.stringify(error), + }, + }); + } + }, + }; + }, +}); + +export default rule; diff --git a/src/agent/no-unused-function-argument.spec.ts b/src/agent/no-unused-function-argument.spec.ts new file mode 100644 index 0000000..04e3511 --- /dev/null +++ b/src/agent/no-unused-function-argument.spec.ts @@ -0,0 +1,87 @@ +// agent/no-unused-function-argument.spec.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import createTester from '../ts-tester.test'; +import rule, { ruleId } from './no-unused-function-argument'; + +createTester().run(ruleId, rule, { + valid: [ + { + name: 'all function arguments are used', + code: `function doSomething(a: string, b: number, c: unknown) { console.log(a,b,c); }`, + }, + { + name: 'argument referenced not directly in function body can be associated', + code: ` + function doSomething(a: string) { + try { + console.log(a); + } catch (error) { + // + } + } + `, + }, + { + name: 'argument referenced in child function declaration', + code: ` + function doSomething(a: string) { + function doSomethingElse() { + console.log(a); + } + } + `, + }, + ], + invalid: [ + { + name: 'remove unused function arguments - first argument', + code: `function doSomething(a: string, b: number, c: unknown,) { console.log(b,c); }`, + output: `function doSomething(b: number, c: unknown) { console.log(b,c); }`, + errors: [{ messageId: 'removeUnusedFunctionArguments' }], + }, + { + name: 'remove unused function arguments - last argument', + code: `function doSomething(a: string, b: number, c: unknown,) { console.log(a,b); }`, + output: `function doSomething(a: string, b: number) { console.log(a,b); }`, + errors: [{ messageId: 'removeUnusedFunctionArguments' }], + }, + { + name: 'remove unused function arguments - middle argument', + code: ` + function doSomething(a: string, b: number, c: unknown,) { + console.log(a,c); + } + `, + output: ` + function doSomething(a: string, c: unknown) { + console.log(a,c); + } + `, + errors: [{ messageId: 'removeUnusedFunctionArguments' }], + }, + { + name: 'remove unused function arguments - first and second arguments', + code: `function doSomething(a: string, b: number, c: unknown,) { console.log(c); }`, + output: `function doSomething(c: unknown) { console.log(c); }`, + errors: [{ messageId: 'removeUnusedFunctionArguments' }], + }, + { + name: 'remove unused function arguments - all arguments', + code: `function doSomething(a: string, b: number, c: unknown,) {}`, + output: `function doSomething() {}`, + errors: [{ messageId: 'removeUnusedFunctionArguments' }], + }, + { + name: 'remove unused function arguments - first and last arguments', + code: `function doSomething(a: string, b: number, c: unknown) { console.log(b); }`, + output: `function doSomething(b: number) { console.log(b); }`, + errors: [{ messageId: 'removeUnusedFunctionArguments' }], + }, + ], +}); diff --git a/src/agent/no-unused-function-argument.ts b/src/agent/no-unused-function-argument.ts new file mode 100644 index 0000000..dddb12d --- /dev/null +++ b/src/agent/no-unused-function-argument.ts @@ -0,0 +1,98 @@ +// agent/no-unused-function-argument.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { strict as assert } from 'node:assert'; + +import { ESLintUtils, TSESTree } from '@typescript-eslint/utils'; +import type { Scope } from '@typescript-eslint/utils/ts-eslint'; + +import getDocumentationUrl from '../get-documentation-url'; + +export const ruleId = 'no-unused-function-argument'; + +const createRule = ESLintUtils.RuleCreator((name) => getDocumentationUrl(name)); + +const rule: ESLintUtils.RuleModule<'unknownError' | 'removeUnusedFunctionArguments'> = createRule({ + name: ruleId, + meta: { + type: 'suggestion', + docs: { + description: 'Remove unused function arguments.', + }, + messages: { + removeUnusedFunctionArguments: 'Removing unused function arguments.', + unknownError: 'Unknown error occurred in file "{{fileName}}": {{ error }}.', + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + + function isParameterUsed(parameter: TSESTree.Identifier, scope: Scope.Scope): boolean { + return ( + scope.references.some((ref) => ref.identifier.name === parameter.name) || + scope.childScopes.some((childScope) => isParameterUsed(parameter, childScope)) + ); + } + + return { + FunctionDeclaration(functionDeclaration: TSESTree.FunctionDeclaration) { + try { + const parameters = functionDeclaration.params; + if (parameters.length === 0) { + return; + } + + const functionScope = sourceCode.getScope(functionDeclaration); + const parametersToKeep = parameters.filter( + (parameter) => + parameter.type !== TSESTree.AST_NODE_TYPES.Identifier || isParameterUsed(parameter, functionScope), + ); + if (parametersToKeep.length === parameters.length) { + return; + } + + const updatedParameters = parametersToKeep.map((parameter) => sourceCode.getText(parameter)).join(', '); + context.report({ + node: functionDeclaration, + messageId: 'removeUnusedFunctionArguments', + fix(fixer) { + const firstParameter = parameters[0]; + const lastParameter = parameters.at(-1); + assert.ok(firstParameter !== undefined && lastParameter !== undefined); + const tokenAfterParameters = sourceCode.getTokenAfter(lastParameter); + + return fixer.replaceTextRange( + [ + firstParameter.range[0], + tokenAfterParameters?.value === ',' ? tokenAfterParameters.range[1] : lastParameter.range[1], + ], + updatedParameters, + ); + }, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to apply ${ruleId} rule for file "${context.filename}":`, error); + context.report({ + node: functionDeclaration, + messageId: 'unknownError', + data: { + fileName: context.filename, + error: error instanceof Error ? error.toString() : JSON.stringify(error), + }, + }); + } + }, + }; + }, +}); + +export default rule; diff --git a/src/agent/no-unused-imports.spec.ts b/src/agent/no-unused-imports.spec.ts new file mode 100644 index 0000000..1769baa --- /dev/null +++ b/src/agent/no-unused-imports.spec.ts @@ -0,0 +1,94 @@ +// agent/no-unused-imports.spec.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import createTester from '../ts-tester.test'; +import rule, { ruleId } from './no-unused-imports'; + +createTester().run(ruleId, rule, { + valid: [ + { + name: 'import not used but not from desired module', + code: `import { SomeType } from 'some-module';`, + }, + { + name: 'import used on top level', + code: ` + import type { Configuration } from '@checkdigit/serve-runtime'; + let config: Configuration; + `, + }, + { + name: 'multiple imports used on top level', + code: ` + import { type Configuration, EMPTY_CONTEXT } from '@checkdigit/fixtures'; + let config: Configuration; + let context: EMPTY_CONTEXT; + `, + }, + { + name: 'import used in function declaration', + code: ` + import type { Configuration } from '@checkdigit/serve-runtime'; + export default async function(config: Configuration): Promise { + // do something + } + `, + }, + { + name: 'import used in nested scope', + code: ` + import type { Configuration } from '@checkdigit/serve-runtime'; + export default async function(): Promise { + try { + let config: Configuration; + } catch (error) { + // do something + } + } + `, + }, + ], + invalid: [ + { + name: 'remove unused import', + code: `import type { Configuration } from '@checkdigit/serve-runtime';`, + output: ``, + errors: [{ messageId: 'removeUnusedImports' }], + }, + { + name: 'remove multiple unused imports', + code: `import type { Configuration, Fixture } from '@checkdigit/fixture';`, + output: ``, + errors: [{ messageId: 'removeUnusedImports' }], + }, + { + name: 'remove partial unused import - type only', + code: ` + import type { Configuration, Fixture } from '@checkdigit/fixture'; + let config: Configuration; + `, + output: ` + import type { Configuration } from '@checkdigit/fixture'; + let config: Configuration; + `, + errors: [{ messageId: 'removeUnusedImports' }], + }, + { + name: 'remove partial unused import - mixed type and value', + code: ` + import { EMPTY_CONTEXT, type Fixture } from '@checkdigit/fixture'; + let fixture: Fixture; + `, + output: ` + import { type Fixture } from '@checkdigit/fixture'; + let fixture: Fixture; + `, + errors: [{ messageId: 'removeUnusedImports' }], + }, + ], +}); diff --git a/src/agent/no-unused-imports.ts b/src/agent/no-unused-imports.ts new file mode 100644 index 0000000..d652511 --- /dev/null +++ b/src/agent/no-unused-imports.ts @@ -0,0 +1,103 @@ +// agent/no-unused-imports.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { ESLintUtils, TSESTree } from '@typescript-eslint/utils'; +import type { Scope } from '@typescript-eslint/utils/ts-eslint'; + +import getDocumentationUrl from '../get-documentation-url'; + +export const ruleId = 'no-unused-imports'; + +const createRule = ESLintUtils.RuleCreator((name) => getDocumentationUrl(name)); + +const rule: ESLintUtils.RuleModule<'unknownError' | 'removeUnusedImports'> = createRule({ + name: ruleId, + meta: { + type: 'suggestion', + docs: { + description: 'Remove unused imports.', + }, + messages: { + removeUnusedImports: 'Removing unused imports.', + unknownError: 'Unknown error occurred in file "{{fileName}}": {{ error }}.', + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + + function isImportUsed(specifier: TSESTree.ImportClause, scope: Scope.Scope): boolean { + return ( + specifier.type !== TSESTree.AST_NODE_TYPES.ImportSpecifier || + scope.references.some((ref) => ref.identifier.name === specifier.local.name) || + scope.childScopes.some((childScope) => isImportUsed(specifier, childScope)) + ); + } + + return { + ImportDeclaration(importDeclaration) { + try { + const moduleName = importDeclaration.source.value; + if ( + !importDeclaration.specifiers.every( + (specifier) => specifier.type === TSESTree.AST_NODE_TYPES.ImportSpecifier, + ) || + // [TODO:] move to meta schema + !['@checkdigit/serve-runtime', '@checkdigit/fixture'].includes(moduleName) + ) { + return; + } + + const originalSpecifiers = importDeclaration.specifiers; + const scope = sourceCode.getScope(importDeclaration); + const usedSpecifiers = originalSpecifiers.filter((specifier) => isImportUsed(specifier, scope)); + if (usedSpecifiers.length === originalSpecifiers.length) { + return; + } + + if (usedSpecifiers.length === 0) { + context.report({ + messageId: 'removeUnusedImports', + node: importDeclaration, + *fix(fixer) { + yield fixer.remove(importDeclaration); + }, + }); + return; + } + + const usedSpecifierTexts = usedSpecifiers.map((specifier) => sourceCode.getText(specifier)); + const updatedImportDeclaration = `import ${importDeclaration.importKind === 'type' ? 'type ' : ''}{ ${usedSpecifierTexts.join(', ')} } from '${moduleName}';`; + + context.report({ + messageId: 'removeUnusedImports', + node: importDeclaration, + *fix(fixer) { + yield fixer.replaceText(importDeclaration, updatedImportDeclaration); + }, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to apply ${ruleId} rule for file "${context.filename}":`, error); + context.report({ + node: importDeclaration, + messageId: 'unknownError', + data: { + fileName: context.filename, + error: error instanceof Error ? error.toString() : JSON.stringify(error), + }, + }); + } + }, + }; + }, +}); + +export default rule; diff --git a/src/agent/no-unused-service-variable.spec.ts b/src/agent/no-unused-service-variable.spec.ts new file mode 100644 index 0000000..3b36744 --- /dev/null +++ b/src/agent/no-unused-service-variable.spec.ts @@ -0,0 +1,44 @@ +// agent/no-unused-service-variable.spec.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import createTester from '../ts-tester.test'; +import rule, { ruleId } from './no-unused-service-variable'; + +createTester().run(ruleId, rule, { + valid: [ + { + name: 'used service variable', + code: ` + function doSomething() { + const someService = fixture.config.service.xxx(EMPTY_CONTEXT); + await someService.doSomething(); + } + `, + }, + { + name: 'non-service variable', + code: ` + function doSomething() { + const notService = stuff; + } + `, + }, + ], + invalid: [ + { + name: 'remove unused service variable', + code: `function doSomething() { + const someService = fixture.config.service.xxx(EMPTY_CONTEXT); + }`, + output: `function doSomething() { + + }`, + errors: [{ messageId: 'removeUnusedServiceVariables' }], + }, + ], +}); diff --git a/src/agent/no-unused-service-variable.ts b/src/agent/no-unused-service-variable.ts new file mode 100644 index 0000000..126bf69 --- /dev/null +++ b/src/agent/no-unused-service-variable.ts @@ -0,0 +1,93 @@ +// agent/no-unused-service-variable.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { strict as assert } from 'node:assert'; + +import { ESLintUtils, TSESTree } from '@typescript-eslint/utils'; +import type { Scope } from '@typescript-eslint/utils/ts-eslint'; + +import getDocumentationUrl from '../get-documentation-url'; +import { getEnclosingScopeNode } from '../library/ts-tree'; + +export const ruleId = 'no-unused-service-variable'; + +const createRule = ESLintUtils.RuleCreator((name) => getDocumentationUrl(name)); + +const rule: ESLintUtils.RuleModule<'unknownError' | 'removeUnusedServiceVariables'> = createRule({ + name: ruleId, + meta: { + type: 'suggestion', + docs: { + description: 'Remove unused service variables.', + }, + messages: { + removeUnusedServiceVariables: 'Removing unused service variables.', + unknownError: 'Unknown error occurred in file "{{fileName}}": {{ error }}.', + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + const scopeManager = sourceCode.scopeManager; + + function isVariableUsed(variableIdentifier: TSESTree.Identifier, scope: Scope.Scope): boolean { + const variable = scope.variables.find((variableToCheck) => variableToCheck.name === variableIdentifier.name); + return variable !== undefined && variable.references.length > 1; + } + + return { + VariableDeclaration(variableDeclaration: TSESTree.VariableDeclaration) { + try { + if ( + variableDeclaration.declarations.length !== 1 || + !sourceCode.getText(variableDeclaration).includes('.service.') + ) { + return; + } + + const enclosingScopeNode = getEnclosingScopeNode(variableDeclaration); + assert.ok(enclosingScopeNode, 'enclosingScopeNode is undefined'); + + const declarator = variableDeclaration.declarations[0]; + if (declarator.id.type !== TSESTree.AST_NODE_TYPES.Identifier) { + return; + } + + const scope = scopeManager?.acquire(enclosingScopeNode); + assert.ok(scope, 'variable declaration is undefined'); + if (isVariableUsed(declarator.id, scope)) { + return; + } + + context.report({ + node: variableDeclaration, + messageId: 'removeUnusedServiceVariables', + fix(fixer) { + return fixer.remove(variableDeclaration); + }, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to apply ${ruleId} rule for file "${context.filename}":`, error); + context.report({ + node: variableDeclaration, + messageId: 'unknownError', + data: { + fileName: context.filename, + error: error instanceof Error ? error.toString() : JSON.stringify(error), + }, + }); + } + }, + }; + }, +}); + +export default rule; diff --git a/src/agent/response-reference.ts b/src/agent/response-reference.ts new file mode 100644 index 0000000..7108656 --- /dev/null +++ b/src/agent/response-reference.ts @@ -0,0 +1,153 @@ +// agent/response-reference.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { strict as assert } from 'node:assert'; + +import debug from 'debug'; + +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; +import type { ScopeManager, Variable } from '@typescript-eslint/scope-manager'; +import { getParent } from '../library/ts-tree'; + +const log = debug('eslint-plugin:response-reference'); + +/** + * analyze response related variables and their references + * the implementation is for fixture API, but it can be used for fetch API as well since the tree structure is similar + * @param variableDeclaration - variable declaration node + */ +// eslint-disable-next-line sonarjs/cognitive-complexity +export function analyzeResponseReferences( + variableDeclaration: TSESTree.VariableDeclaration | undefined, + scopeManager: ScopeManager, +): { + variable?: Variable; + bodyReferences: TSESTree.MemberExpression[]; + // headersReferences: TSESTree.MemberExpression[]; + statusReferences: TSESTree.MemberExpression[]; + destructuringBodyVariable?: Variable | TSESTree.ObjectPattern; + destructuringHeadersVariable?: Variable | TSESTree.ObjectPattern; + destructuringStatusVariable?: Variable | TSESTree.ObjectPattern; + destructuringHeadersReferences?: TSESTree.MemberExpression[] | undefined; +} { + const results: { + variable?: Variable; + bodyReferences: TSESTree.MemberExpression[]; + // headersReferences: TSESTree.MemberExpression[]; + statusReferences: TSESTree.MemberExpression[]; + destructuringBodyVariable?: Variable | TSESTree.ObjectPattern; + destructuringHeadersVariable?: Variable | TSESTree.ObjectPattern; + destructuringStatusVariable?: Variable | TSESTree.ObjectPattern; + destructuringHeadersReferences?: TSESTree.MemberExpression[] | undefined; + } = { + bodyReferences: [], + // headersReferences: [], + statusReferences: [], + }; + if (!variableDeclaration) { + return results; + } + + const responseVariables = scopeManager.getDeclaredVariables(variableDeclaration); + for (const responseVariable of responseVariables) { + const identifier = responseVariable.identifiers[0]; + assert.ok(identifier); + const identifierParent = getParent(identifier); + assert.ok(identifierParent); + if (identifierParent.type === AST_NODE_TYPES.VariableDeclarator) { + // e.g. const response = ... + results.variable = responseVariable; + const responseReferences = responseVariable.references.map((responseReference) => + getParent(responseReference.identifier), + ); + // e.g. response.body + results.bodyReferences = responseReferences.filter( + (node): node is TSESTree.MemberExpression => + node?.type === AST_NODE_TYPES.MemberExpression && + node.property.type === AST_NODE_TYPES.Identifier && + node.property.name === 'body', + ); + // // e.g. response.headers / response.header / response.get() + // results.headersReferences = responseReferences.filter( + // (node): node is TSESTree.MemberExpression => + // node?.type === AST_NODE_TYPES.MemberExpression && + // node.property.type === AST_NODE_TYPES.Identifier && + // (node.property.name === 'header' || node.property.name === 'headers' || node.property.name === 'get'), + // ); + // e.g. response.status / response.statusCode + results.statusReferences = responseReferences.filter( + (node): node is TSESTree.MemberExpression => + node?.type === AST_NODE_TYPES.MemberExpression && + node.property.type === AST_NODE_TYPES.Identifier && + (node.property.name === 'status' || node.property.name === 'statusCode'), + ); + } else if ( + // body reference through destruction/renaming, e.g. "const { body } = ..." + identifierParent.type === AST_NODE_TYPES.Property && + identifierParent.key.type === AST_NODE_TYPES.Identifier && + identifierParent.key.name === 'body' + ) { + results.destructuringBodyVariable = responseVariable; + } else if ( + // body reference through destruction/renaming, e.g. "const { body } = ..." + identifierParent.type === AST_NODE_TYPES.Property && + identifierParent.key.type === AST_NODE_TYPES.Identifier && + (identifierParent.key.name === 'status' || identifierParent.key.name === 'statusCode') + ) { + results.destructuringStatusVariable = responseVariable; + } else if ( + // header reference through destruction/renaming, e.g. "const { headers } = ..." + identifierParent.type === AST_NODE_TYPES.Property && + identifierParent.key.type === AST_NODE_TYPES.Identifier && + (identifierParent.key.name === 'headers' || identifierParent.key.name === 'header') + ) { + results.destructuringHeadersVariable = responseVariable; + results.destructuringHeadersReferences = responseVariable.references + .map((reference) => reference.identifier) + .map(getParent) + .filter( + (parent): parent is TSESTree.MemberExpression => + parent?.type === AST_NODE_TYPES.MemberExpression && + parent.property.type === AST_NODE_TYPES.Identifier && + parent.property.name !== 'get' && + getParent(parent)?.type !== AST_NODE_TYPES.CallExpression, + ); + } else if (identifierParent.type === AST_NODE_TYPES.Property) { + const parent = getParent(identifierParent); + if (parent?.type === AST_NODE_TYPES.ObjectPattern) { + // body reference through nested destruction, e.g. "const { body: {bodyPropertyName: renamedBodyPropertyName}, headers: {headerPropertyName: renamedHeaderPropertyName} } = ..." + const parent2 = getParent(parent); + if ( + parent2?.type === AST_NODE_TYPES.Property && + parent2.key.type === AST_NODE_TYPES.Identifier && + parent2.key.name === 'body' + ) { + results.destructuringBodyVariable = parent; + } + if ( + parent2?.type === AST_NODE_TYPES.Property && + parent2.key.type === AST_NODE_TYPES.Identifier && + (parent2.key.name === 'status' || parent2.key.name === 'statusCode') + ) { + results.destructuringStatusVariable = parent; + } + if ( + parent2?.type === AST_NODE_TYPES.Property && + parent2.key.type === AST_NODE_TYPES.Identifier && + (parent2.key.name === 'header' || parent2.key.name === 'headers') + ) { + results.destructuringHeadersVariable = parent; + } + } + } else { + log('+++++++ can not handle identifierParent', identifierParent); + throw new Error(`Unknown response variable reference: ${responseVariable.name}`); + } + } + return results; +} diff --git a/src/agent/supertest-then.spec.ts b/src/agent/supertest-then.spec.ts new file mode 100644 index 0000000..96dd6c7 --- /dev/null +++ b/src/agent/supertest-then.spec.ts @@ -0,0 +1,84 @@ +// agent/supertest-then.spec.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import createTester from '../ts-tester.test'; +import rule, { ruleId } from './supertest-then'; + +createTester().run(ruleId, rule, { + valid: [ + { + name: 'skip regular supertest calls which will be handled in "no-expect-assertion" rule', + code: ` + const pingResponse = ping().expect(StatusCodes.OK); + const body = pingResponse.body; + const timeDifference = Date.now() - new Date(body.serverTime).getTime(); + assert.ok(timeDifference >= 0 && timeDifference < 200); + `, + }, + ], + invalid: [ + { + name: 'with assertions', + code: ` + const responses = await Promise.all([ + ping().expect(StatusCodes.NO_CONTENT), + ping().expect(StatusCodes.NO_CONTENT), + ]); + `, + output: ` + const responses = await Promise.all([ + // eslint-disable-next-line @checkdigit/no-promise-instance-method + ping().then((res) => { + assert.equal(res.status, StatusCodes.NO_CONTENT); + return res; + }), + // eslint-disable-next-line @checkdigit/no-promise-instance-method + ping().then((res) => { + assert.equal(res.status, StatusCodes.NO_CONTENT); + return res; + }), + ]); + `, + errors: [{ messageId: 'preferNativeFetch' }, { messageId: 'preferNativeFetch' }], + }, + { + name: 'in non-async arrow function with concurrent promises', + code: ` + await Promise.all( + Object.keys(zoneKeyPartImportRequest).map((propertyName) => { + const requestWithPropertyMissing = omit( + zoneKeyPartImportRequest, + propertyName, + ); + return ( + ping().expect(StatusCodes.BAD_REQUEST) + ); + }), + ); + `, + output: ` + await Promise.all( + Object.keys(zoneKeyPartImportRequest).map((propertyName) => { + const requestWithPropertyMissing = omit( + zoneKeyPartImportRequest, + propertyName, + ); + return ( + // eslint-disable-next-line @checkdigit/no-promise-instance-method + ping().then((res) => { + assert.equal(res.status, StatusCodes.BAD_REQUEST); + return res; + }) + ); + }), + ); + `, + errors: [{ messageId: 'preferNativeFetch' }], + }, + ], +}); diff --git a/src/agent/supertest-then.ts b/src/agent/supertest-then.ts new file mode 100644 index 0000000..709f3e0 --- /dev/null +++ b/src/agent/supertest-then.ts @@ -0,0 +1,230 @@ +// agent/supertest-then.ts + +/* + * Copyright (c) 2021-2024 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { strict as assert } from 'node:assert'; + +import { AST_NODE_TYPES, ESLintUtils, TSESTree } from '@typescript-eslint/utils'; +import type { SourceCode } from '@typescript-eslint/utils/ts-eslint'; + +import { getEnclosingFunction, getParent, isUsedInArrayOrAsArgument } from '../library/ts-tree'; +import getDocumentationUrl from '../get-documentation-url'; +import { getIndentation } from '../library/format'; + +export const ruleId = 'supertest-then'; + +interface FixtureCallInformation { + fixtureNode: TSESTree.CallExpression; + requestBody?: TSESTree.Expression; + requestHeaders?: { name: TSESTree.Expression; value: TSESTree.Expression }[]; + assertions?: TSESTree.Expression[][]; +} + +// recursively analyze the fixture/supertest call chain to collect information of request/response +function analyzeFixtureCall(call: TSESTree.CallExpression, results: FixtureCallInformation, sourceCode: SourceCode) { + const parent = getParent(call); + if (!parent) { + return; + } + + let nextCall; + if (parent.type !== AST_NODE_TYPES.MemberExpression) { + results.fixtureNode = call; + return; + } + + if (parent.property.type === AST_NODE_TYPES.Identifier) { + if (parent.property.name === 'expect') { + // supertest assertions + const assertionCall = getParent(parent); + assert.ok(assertionCall && assertionCall.type === AST_NODE_TYPES.CallExpression); + results.assertions = [...(results.assertions ?? []), assertionCall.arguments as TSESTree.Expression[]]; + nextCall = assertionCall; + } + } else { + throw new Error(`Unexpected TSESTree.Expression in fixture/supertest call ${sourceCode.getText(parent)}.`); + } + if (nextCall) { + analyzeFixtureCall(nextCall, results, sourceCode); + } +} + +// eslint-disable-next-line sonarjs/cognitive-complexity +function createResponseAssertions( + fixtureCallInformation: FixtureCallInformation, + sourceCode: SourceCode, + responseVariableName: string, +) { + let statusAssertion: string | undefined; + const nonStatusAssertions: string[] = []; + for (const expectArguments of fixtureCallInformation.assertions ?? []) { + if (expectArguments.length === 1) { + const [assertionArgument] = expectArguments; + assert.ok(assertionArgument); + if ( + (assertionArgument.type === AST_NODE_TYPES.MemberExpression && + assertionArgument.object.type === AST_NODE_TYPES.Identifier && + assertionArgument.object.name === 'StatusCodes') || + assertionArgument.type === AST_NODE_TYPES.Literal || + sourceCode.getText(assertionArgument).includes('StatusCodes.') + ) { + // status code assertion + statusAssertion = `assert.equal(${responseVariableName}.status, ${sourceCode.getText(assertionArgument)})`; + } else if (assertionArgument.type === AST_NODE_TYPES.ArrowFunctionExpression) { + // callback assertion using arrow function + let functionBody = sourceCode.getText(assertionArgument.body); + + const [originalResponseArgument] = assertionArgument.params; + assert.ok(originalResponseArgument?.type === AST_NODE_TYPES.Identifier); + const originalResponseArgumentName = originalResponseArgument.name; + if (originalResponseArgumentName !== responseVariableName) { + functionBody = functionBody.replace( + new RegExp(`\\b${originalResponseArgumentName}\\b`, 'ug'), + responseVariableName, + ); + } + nonStatusAssertions.push(`assert.doesNotThrow(()=>${functionBody})`); + } else if (assertionArgument.type === AST_NODE_TYPES.Identifier) { + // callback assertion using function reference + nonStatusAssertions.push( + `assert.doesNotThrow(()=>${sourceCode.getText(assertionArgument)}(${responseVariableName}))`, + ); + } else if ( + assertionArgument.type === AST_NODE_TYPES.ObjectExpression || + assertionArgument.type === AST_NODE_TYPES.CallExpression + ) { + // body deep equal assertion + nonStatusAssertions.push( + `assert.deepEqual(await ${responseVariableName}.json(), ${sourceCode.getText(assertionArgument)})`, + ); + } else { + throw new Error(`Unexpected Supertest assertion argument: ".expect(${sourceCode.getText(assertionArgument)})`); + } + } else if (expectArguments.length === 2) { + // header assertion + const [headerName, headerValue] = expectArguments; + assert.ok(headerName && headerValue); + const headersReference = `${responseVariableName}.headers`; + if (headerValue.type === AST_NODE_TYPES.Literal && headerValue.value instanceof RegExp) { + nonStatusAssertions.push( + `assert.ok(${headersReference}.get(${sourceCode.getText(headerName)}).match(${sourceCode.getText(headerValue)}))`, + ); + } else { + nonStatusAssertions.push( + `assert.equal(${headersReference}.get(${sourceCode.getText(headerName)}), ${sourceCode.getText(headerValue)})`, + ); + } + } + } + return { + statusAssertion, + nonStatusAssertions, + }; +} + +const createRule = ESLintUtils.RuleCreator((name) => getDocumentationUrl(name)); +const rule: ESLintUtils.RuleModule< + 'unknownError' | 'preferNativeFetch' + // | 'shouldUseHeaderGetter' +> = createRule({ + name: ruleId, + meta: { + type: 'suggestion', + docs: { + description: 'Prefer native fetch API over customized fixture API.', + url: getDocumentationUrl(ruleId), + }, + messages: { + preferNativeFetch: 'Prefer native fetch API over customized fixture API.', + // shouldUseHeaderGetter: 'Getter should be used to access response headers.', + unknownError: 'Unknown error occurred in file "{{fileName}}": {{ error }}.', + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + const scopeManager = sourceCode.scopeManager; + assert.ok(scopeManager); + + return { + 'CallExpression[callee.property.name="expect"]': (supertestCall: TSESTree.CallExpression) => { + try { + assert.ok(supertestCall.callee.type === AST_NODE_TYPES.MemberExpression); + if ( + supertestCall.callee.object.type !== AST_NODE_TYPES.CallExpression || + (supertestCall.callee.object.callee.type === AST_NODE_TYPES.MemberExpression && + supertestCall.callee.object.callee.property.type === AST_NODE_TYPES.Identifier && + supertestCall.callee.object.callee.property.name === 'expect') + ) { + // skip nested expect calls, only focus on the top level + return; + } + + if (!(isUsedInArrayOrAsArgument(supertestCall) || getEnclosingFunction(supertestCall)?.async === false)) { + return; + } + + const fixtureFunction = supertestCall.callee.object; + const indentation = getIndentation(supertestCall, sourceCode); + + const [urlArgumentNode] = supertestCall.arguments; // e.g. `/sample-service/v1/ping` + assert.ok(urlArgumentNode !== undefined); + + const fixtureCallInformation = {} as FixtureCallInformation; + analyzeFixtureCall(fixtureFunction, fixtureCallInformation, sourceCode); + + const responseVariableNameToUse = 'res'; + const { statusAssertion, nonStatusAssertions } = createResponseAssertions( + fixtureCallInformation, + sourceCode, + responseVariableNameToUse, + ); + + // add variable declaration if needed + const disableLintComment = '// eslint-disable-next-line @checkdigit/no-promise-instance-method'; + const fetchCallText = sourceCode.getText(fixtureFunction); + const appendingAssignmentAndAssertionText = [ + ...(statusAssertion !== undefined ? [statusAssertion] : []), + ...nonStatusAssertions, + ].join(`;\n${indentation}`); + const replacementText = fixtureCallInformation.assertions + ? [ + disableLintComment, + `${fetchCallText}.then((${responseVariableNameToUse}) => {`, + appendingAssignmentAndAssertionText === '' ? '' : ` ${appendingAssignmentAndAssertionText};`, + ` return ${responseVariableNameToUse};`, + `})`, + ].join(`\n${indentation}`) + : fetchCallText; + + context.report({ + node: supertestCall, + messageId: 'preferNativeFetch', + fix(fixer) { + return fixer.replaceText(fixtureCallInformation.fixtureNode, replacementText); + }, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to apply ${ruleId} rule for file "${context.filename}":`, error); + context.report({ + node: supertestCall, + messageId: 'unknownError', + data: { + fileName: context.filename, + error: error instanceof Error ? error.toString() : JSON.stringify(error), + }, + }); + } + }, + }; + }, +}); + +export default rule; diff --git a/src/agent/url.spec.ts b/src/agent/url.spec.ts new file mode 100644 index 0000000..6050a1b --- /dev/null +++ b/src/agent/url.spec.ts @@ -0,0 +1,66 @@ +// src/agent/url.test.ts + +import { strict as assert } from 'node:assert'; +import { describe, it } from '@jest/globals'; +import { + addBasePathUrlDomain, + isServiceApiCallUrl, + replaceEndpointUrlPrefixWithBasePath, + replaceEndpointUrlPrefixWithDomain, +} from './url'; + +describe('URL Utility Functions', () => { + it('PLAIN_URL_REGEXP should match plain URLs - string', () => { + const url = "'/service-name/v1/endpoint'"; + assert(isServiceApiCallUrl(url)); + }); + + it('PLAIN_URL_REGEXP should match tokenized URLs - template literal', () => { + const url = '`/service-name/v1/endpoint`'; + assert(isServiceApiCallUrl(url)); + }); + + it('TOKENIZED_URL_REGEXP should match tokenized URLs - string', () => { + // eslint-disable-next-line no-template-curly-in-string + const url = '`${BASE_PATH}/endpoint`'; + assert(isServiceApiCallUrl(url)); + }); + + it('replaceEndpointUrlPrefixWithBasePath should replace URL prefix with BASE_PATH - string', () => { + const url = "'/service-name/v1/endpoint'"; + // eslint-disable-next-line no-template-curly-in-string + const expected = '`${BASE_PATH}/endpoint`'; + assert.equal(replaceEndpointUrlPrefixWithBasePath(url), expected); + }); + + it('replaceEndpointUrlPrefixWithBasePath should replace URL prefix with BASE_PATH - template literal', () => { + const url = '`/service-name/v1/endpoint`'; + // eslint-disable-next-line no-template-curly-in-string + const expected = '`${BASE_PATH}/endpoint`'; + assert.equal(replaceEndpointUrlPrefixWithBasePath(url), expected); + }); + + it('replaceEndpointUrlPrefixWithDomain should replace URL prefix with domain - string', () => { + const url = "'/service-name/v1/endpoint'"; + const expected = "'https://service-name.checkdigit/service-name/v1/endpoint'"; + assert.equal(replaceEndpointUrlPrefixWithDomain(url), expected); + }); + + it('replaceEndpointUrlPrefixWithDomain should replace URL prefix with domain - template literal', () => { + const url = '`/service-name/v1/endpoint`'; + const expected = '`https://service-name.checkdigit/service-name/v1/endpoint`'; + assert.equal(replaceEndpointUrlPrefixWithDomain(url), expected); + }); + + it('addBasePathUrlDomain should add domain to base path URL - string', () => { + const url = "'/service-name/v1'"; + const expected = "'https://service-name.checkdigit/service-name/v1'"; + assert.equal(addBasePathUrlDomain(url), expected); + }); + + it('addBasePathUrlDomain should add domain to base path URL - template literal', () => { + const url = '`/service-name/v1`'; + const expected = '`https://service-name.checkdigit/service-name/v1`'; + assert.equal(addBasePathUrlDomain(url), expected); + }); +}); diff --git a/src/agent/url.ts b/src/agent/url.ts new file mode 100644 index 0000000..7224d8c --- /dev/null +++ b/src/agent/url.ts @@ -0,0 +1,32 @@ +// agent/url.ts + +// eslint-disable-next-line @typescript-eslint/no-inferrable-types +const PLAIN_URL_REGEXP: RegExp = /^[`']\/\w+(?-\w+)*\/v\d+\/(?.|\r|\n)+[`']$/u; +// eslint-disable-next-line @typescript-eslint/no-inferrable-types +const TOKENIZED_URL_REGEXP: RegExp = /^`\$\{(?[A-Z]+_)*BASE_PATH\}\/(?.|\r|\n)+`$/u; + +export function isServiceApiCallUrl(url: string): boolean { + return PLAIN_URL_REGEXP.test(url) || TOKENIZED_URL_REGEXP.test(url); +} + +export function replaceEndpointUrlPrefixWithBasePath(url: string): string { + return url.replace( + /^(?[`'])\/(?\w+(?-\w+)*)(?\/v\d+\/)(?(?.|\r|\n)+)(?[`'])$/u, + // eslint-disable-next-line no-template-curly-in-string + '`${BASE_PATH}/$5`', + ); +} + +export function replaceEndpointUrlPrefixWithDomain(url: string): string { + return url.replace( + /^(?[`'])\/(?\w+(?-\w+)*)(?\/v\d+\/(?.|\r|\n)+(?[`'])$)/u, + '$1https://$2.checkdigit/$2$4', + ); +} + +export function addBasePathUrlDomain(url: string): string { + return url.replace( + /^(?[`'])\/(?\w+(?-\w+)*)(?\/v\d+(?[`'])$)/u, + '$1https://$2.checkdigit/$2$4', + ); +} diff --git a/src/index.ts b/src/index.ts index 89cbb38..151d731 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,10 +8,32 @@ import type { TSESLint } from '@typescript-eslint/utils'; +import addUrlDomain, { ruleId as addUrlDomainRuleId } from './agent/add-url-domain'; +import agentTestWiring, { ruleId as agentTestWiringRuleId } from './agent/agent-test-wiring'; +import fetchResponseBodyJson, { ruleId as fetchResponseBodyJsonRuleId } from './agent/fetch-response-body-json'; +import fetchResponseHeaderGetter, { + ruleId as fetchResponseHeaderGetterRuleId, +} from './agent/fetch-response-header-getter'; +import fetchResponseStatus, { ruleId as fetchResponseStatusRuleId } from './agent/fetch-response-status'; +// import fetchThen, { ruleId as fetchThenRuleId } from './agent/fetch-then'; +import fixFunctionCallArguments, { + ruleId as fixFunctionCallArgumentsRuleId, +} from './agent/fix-function-call-arguments'; import invalidJsonStringify, { ruleId as invalidJsonStringifyRuleId } from './invalid-json-stringify'; import noDuplicatedImports, { ruleId as noDuplicatedImportsRuleId } from './no-duplicated-imports'; +import noFixture, { ruleId as noFixtureRuleId } from './agent/no-fixture'; +import noExpectAssertion, { ruleId as noExpectAssertionRuleId } from './agent/no-expect-assertion'; +// import supertestThen, { ruleId as supertestThenRuleId } from './agent/supertest-then'; import noLegacyServiceTyping, { ruleId as noLegacyServiceTypingRuleId } from './no-legacy-service-typing'; +import noMappedResponse, { ruleId as noMappedResponseRuleId } from './agent/no-mapped-response'; import noPromiseInstanceMethod, { ruleId as noPromiseInstanceMethodRuleId } from './no-promise-instance-method'; +import noServiceWrapper, { ruleId as noServiceWrapperRuleId } from './agent/no-service-wrapper'; +import noStatusCode, { ruleId as noStatusCodeRuleId } from './agent/no-status-code'; +import noUnusedFunctionArguments, { + ruleId as noUnusedFunctionArgumentsRuleId, +} from './agent/no-unused-function-argument'; +import noUnusedImports, { ruleId as noUnusedImportsRuleId } from './agent/no-unused-imports'; +import noUnusedServiceVariables, { ruleId as noUnusedServiceVariablesRuleId } from './agent/no-unused-service-variable'; import requireFixedServicesImport, { ruleId as requireFixedServicesImportRuleId, } from './require-fixed-services-import'; @@ -22,6 +44,9 @@ import requireTypeOutOfTypeOnlyImports, { ruleId as requireTypeOutOfTypeOnlyImportsRuleId, } from './require-type-out-of-type-only-imports'; import noServeRuntime, { ruleId as noServeRuntimeRuleId } from './no-serve-runtime'; +import addBasePathConst, { ruleId as addBasePathConstRuleId } from './agent/add-base-path-const'; +import addBasePathImport, { ruleId as addBasePathImportRuleId } from './agent/add-base-path-import'; +import addAssertImport, { ruleId as addAssertImportRuleId } from './agent/add-assert-import'; import filePathComment from './file-path-comment'; import noCardNumbers from './no-card-numbers'; import noSideEffects from './no-side-effects'; @@ -48,12 +73,31 @@ const rules: Record = { 'object-literal-response': objectLiteralResponse, [invalidJsonStringifyRuleId]: invalidJsonStringify, [noPromiseInstanceMethodRuleId]: noPromiseInstanceMethod, + [noFixtureRuleId]: noFixture, + [noExpectAssertionRuleId]: noExpectAssertion, + // [supertestThenRuleId]: supertestThen, + // [fetchThenRuleId]: fetchThen, + [noServiceWrapperRuleId]: noServiceWrapper, + [noStatusCodeRuleId]: noStatusCode, + [fetchResponseBodyJsonRuleId]: fetchResponseBodyJson, + [fetchResponseHeaderGetterRuleId]: fetchResponseHeaderGetter, + [fetchResponseStatusRuleId]: fetchResponseStatus, + [addUrlDomainRuleId]: addUrlDomain, [noLegacyServiceTypingRuleId]: noLegacyServiceTyping, + [noMappedResponseRuleId]: noMappedResponse, [requireResolveFullResponseRuleId]: requireResolveFullResponse, [noDuplicatedImportsRuleId]: noDuplicatedImports, [noServeRuntimeRuleId]: noServeRuntime, + [addBasePathConstRuleId]: addBasePathConst, + [addBasePathImportRuleId]: addBasePathImport, + [addAssertImportRuleId]: addAssertImport, [requireFixedServicesImportRuleId]: requireFixedServicesImport, [requireTypeOutOfTypeOnlyImportsRuleId]: requireTypeOutOfTypeOnlyImports, + [noUnusedFunctionArgumentsRuleId]: noUnusedFunctionArguments, + [noUnusedServiceVariablesRuleId]: noUnusedServiceVariables, + [noUnusedImportsRuleId]: noUnusedImports, + [fixFunctionCallArgumentsRuleId]: fixFunctionCallArguments, + [agentTestWiringRuleId]: agentTestWiring, }; const plugin: TSESLint.FlatConfig.Plugin = { @@ -87,6 +131,27 @@ const configs: Record = { [`@checkdigit/${requireFixedServicesImportRuleId}`]: 'error', [`@checkdigit/${requireTypeOutOfTypeOnlyImportsRuleId}`]: 'error', [`@checkdigit/${noServeRuntimeRuleId}`]: 'error', + // --- agent rules BEGIN --- + [`@checkdigit/${noMappedResponseRuleId}`]: 'off', + [`@checkdigit/${addUrlDomainRuleId}`]: 'off', + [`@checkdigit/${noFixtureRuleId}`]: 'off', + [`@checkdigit/${noExpectAssertionRuleId}`]: 'off', + // [`@checkdigit/${supertestThenRuleId}`]: 'off', + [`@checkdigit/${noServiceWrapperRuleId}`]: 'off', + [`@checkdigit/${noStatusCodeRuleId}`]: 'off', + [`@checkdigit/${fetchResponseBodyJsonRuleId}`]: 'off', + [`@checkdigit/${fetchResponseHeaderGetterRuleId}`]: 'off', + [`@checkdigit/${fetchResponseStatusRuleId}`]: 'off', + // [`@checkdigit/${fetchThenRuleId}`]: 'off', + [`@checkdigit/${noUnusedFunctionArgumentsRuleId}`]: 'off', + [`@checkdigit/${noUnusedServiceVariablesRuleId}`]: 'off', + [`@checkdigit/${noUnusedImportsRuleId}`]: 'off', + [`@checkdigit/${fixFunctionCallArgumentsRuleId}`]: 'off', + [`@checkdigit/${agentTestWiringRuleId}`]: 'off', + [`@checkdigit/${addBasePathConstRuleId}`]: 'off', + [`@checkdigit/${addBasePathImportRuleId}`]: 'off', + [`@checkdigit/${addAssertImportRuleId}`]: 'off', + // --- agent rules END --- }, }, ], @@ -116,6 +181,93 @@ const configs: Record = { [`@checkdigit/${requireFixedServicesImportRuleId}`]: 'off', [`@checkdigit/${requireTypeOutOfTypeOnlyImportsRuleId}`]: 'error', [`@checkdigit/${noServeRuntimeRuleId}`]: 'off', + // --- agent rules BEGIN --- + [`@checkdigit/${noMappedResponseRuleId}`]: 'off', + [`@checkdigit/${addUrlDomainRuleId}`]: 'off', + [`@checkdigit/${noFixtureRuleId}`]: 'off', + [`@checkdigit/${noExpectAssertionRuleId}`]: 'off', + // [`@checkdigit/${supertestThenRuleId}`]: 'off', + [`@checkdigit/${noServiceWrapperRuleId}`]: 'off', + [`@checkdigit/${noStatusCodeRuleId}`]: 'off', + [`@checkdigit/${fetchResponseBodyJsonRuleId}`]: 'off', + [`@checkdigit/${fetchResponseHeaderGetterRuleId}`]: 'off', + [`@checkdigit/${fetchResponseStatusRuleId}`]: 'off', + // [`@checkdigit/${fetchThenRuleId}`]: 'off', + [`@checkdigit/${noUnusedFunctionArgumentsRuleId}`]: 'off', + [`@checkdigit/${noUnusedServiceVariablesRuleId}`]: 'off', + [`@checkdigit/${noUnusedImportsRuleId}`]: 'off', + [`@checkdigit/${fixFunctionCallArgumentsRuleId}`]: 'off', + [`@checkdigit/${agentTestWiringRuleId}`]: 'off', + [`@checkdigit/${addBasePathConstRuleId}`]: 'off', + [`@checkdigit/${addBasePathImportRuleId}`]: 'off', + [`@checkdigit/${addAssertImportRuleId}`]: 'off', + // --- agent rules END --- + }, + }, + ], + 'agent-phase-1-test': [ + { + files: ['**/*.spec.ts', '**/*.test.ts', 'src/api/v*/index.ts'], + // eslint-disable-next-line sonarjs/no-duplicate-string + ignores: ['src/plugin/**'], + plugins: { + '@checkdigit': plugin, + }, + rules: { + [`@checkdigit/${noMappedResponseRuleId}`]: 'error', + [`@checkdigit/${addUrlDomainRuleId}`]: 'error', + [`@checkdigit/${noServiceWrapperRuleId}`]: 'error', + [`@checkdigit/${noStatusCodeRuleId}`]: 'error', + [`@checkdigit/${fetchResponseBodyJsonRuleId}`]: 'error', + [`@checkdigit/${fetchResponseHeaderGetterRuleId}`]: 'error', + [`@checkdigit/${fetchResponseStatusRuleId}`]: 'error', + // [`@checkdigit/${fetchThenRuleId}`]: 'error', + [`@checkdigit/${noUnusedFunctionArgumentsRuleId}`]: 'error', + [`@checkdigit/${noUnusedServiceVariablesRuleId}`]: 'error', + [`@checkdigit/${noUnusedImportsRuleId}`]: 'error', + [`@checkdigit/${fixFunctionCallArgumentsRuleId}`]: 'error', + [`@checkdigit/${addBasePathConstRuleId}`]: 'error', + [`@checkdigit/${addBasePathImportRuleId}`]: 'error', + [`@checkdigit/${addAssertImportRuleId}`]: 'error', + [`@checkdigit/${noFixtureRuleId}`]: 'error', + [`@checkdigit/${noExpectAssertionRuleId}`]: 'error', + // [`@checkdigit/${supertestThenRuleId}`]: 'error', + }, + }, + { + files: ['**/*.spec.ts'], + ignores: ['src/plugin/**'], + plugins: { + '@checkdigit': plugin, + }, + rules: { + [`@checkdigit/${agentTestWiringRuleId}`]: 'error', + }, + }, + ], + 'agent-phase-2-production': [ + { + files: ['**/*.ts'], + ignores: ['src/plugin/**'], + plugins: { + '@checkdigit': plugin, + }, + rules: { + [`@checkdigit/${noMappedResponseRuleId}`]: 'error', + [`@checkdigit/${addUrlDomainRuleId}`]: 'error', + [`@checkdigit/${noServiceWrapperRuleId}`]: 'error', + [`@checkdigit/${noStatusCodeRuleId}`]: 'error', + [`@checkdigit/${fetchResponseBodyJsonRuleId}`]: 'error', + [`@checkdigit/${fetchResponseHeaderGetterRuleId}`]: 'error', + [`@checkdigit/${fetchResponseStatusRuleId}`]: 'error', + // [`@checkdigit/${fetchThenRuleId}`]: 'error', + [`@checkdigit/${noUnusedFunctionArgumentsRuleId}`]: 'error', + [`@checkdigit/${noUnusedServiceVariablesRuleId}`]: 'error', + [`@checkdigit/${noUnusedImportsRuleId}`]: 'error', + [`@checkdigit/${fixFunctionCallArgumentsRuleId}`]: 'error', + [`@checkdigit/${addBasePathConstRuleId}`]: 'error', + [`@checkdigit/${addBasePathImportRuleId}`]: 'error', + [`@checkdigit/${addAssertImportRuleId}`]: 'error', }, }, ], diff --git a/src/require-resolve-full-response.ts b/src/require-resolve-full-response.ts index e9cf7a8..762256b 100644 --- a/src/require-resolve-full-response.ts +++ b/src/require-resolve-full-response.ts @@ -38,8 +38,8 @@ const rule: ESLintUtils.RuleModule<'invalidOptions' | 'unknownError'> = createRu create(context) { const sourceCode = context.sourceCode; const scopeManager = sourceCode.scopeManager; - const parserService = ESLintUtils.getParserServices(context); - const typeChecker = parserService.program.getTypeChecker(); + const parserServices = ESLintUtils.getParserServices(context); + const typeChecker = parserServices.program.getTypeChecker(); function isUrlArgumentValid(urlArgument: TSESTree.Node | undefined, scope: Scope) { if ( @@ -67,7 +67,7 @@ const rule: ESLintUtils.RuleModule<'invalidOptions' | 'unknownError'> = createRu } function getType(identifier: TSESTree.Identifier) { - const variable = parserService.esTreeNodeToTSNodeMap.get(identifier); + const variable = parserServices.esTreeNodeToTSNodeMap.get(identifier); const variableType = typeChecker.getTypeAtLocation(variable); return typeChecker.typeToString(variableType); } @@ -171,7 +171,7 @@ const rule: ESLintUtils.RuleModule<'invalidOptions' | 'unknownError'> = createRu if (optionsTypeString === 'FullResponseOptions') { return; } - const variable = parserService.esTreeNodeToTSNodeMap.get(optionsArgument); + const variable = parserServices.esTreeNodeToTSNodeMap.get(optionsArgument); const optionType = typeChecker.getTypeAtLocation(variable); const resolveWithFullResponseProperty = optionType.getProperty('resolveWithFullResponse'); if (resolveWithFullResponseProperty?.declarations?.[0]?.getText() === 'resolveWithFullResponse: true') { diff --git a/ts-init/package.json b/ts-init/package.json new file mode 100644 index 0000000..8c61cdc --- /dev/null +++ b/ts-init/package.json @@ -0,0 +1,3 @@ +{ + "name": "@checkdigit/ping" +} diff --git a/ts-init/services/checkdigit/openapi-cli/v1/index.ts b/ts-init/services/checkdigit/openapi-cli/v1/index.ts new file mode 100644 index 0000000..daa82c2 --- /dev/null +++ b/ts-init/services/checkdigit/openapi-cli/v1/index.ts @@ -0,0 +1,6 @@ +/* c8 ignore start */ +// services/checkdigit/openapi-cli/v1/index.ts + +export type * as openapiCliV1 from './swagger'; + +/* c8 ignore stop */ diff --git a/ts-init/services/checkdigit/openapi-cli/v1/interface.d.ts b/ts-init/services/checkdigit/openapi-cli/v1/interface.d.ts new file mode 100644 index 0000000..b17dd4b --- /dev/null +++ b/ts-init/services/checkdigit/openapi-cli/v1/interface.d.ts @@ -0,0 +1,29 @@ +/* c8 ignore start */ +// services/checkdigit/openapi-cli/v1/interface.d.ts + +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ + +import type { MappedRequestBody, MappedRequestHeaders, MappedResponse, CaseInsensitive } from '../../../index'; +import type * as types from './swagger'; + +declare global { + function fetch( + url: `https://openapi-cli.checkdigit/sample/v1/tenant/${string}/rsa-key-pair/${string}`, + init: Omit & { + method: CaseInsensitive<'PUT'>; + } & MappedRequestHeaders & + MappedRequestBody, + ): Promise>; + + function fetch( + url: `https://openapi-cli.checkdigit/sample/v1/ping`, + init?: Omit & { + method?: CaseInsensitive<'GET'>; + } & MappedRequestHeaders<{} | undefined>, + ): Promise>; +} + +/* eslint-enable */ + +/* c8 ignore stop */ diff --git a/ts-init/services/checkdigit/openapi-cli/v1/swagger.ts b/ts-init/services/checkdigit/openapi-cli/v1/swagger.ts new file mode 100644 index 0000000..fc2f0c0 --- /dev/null +++ b/ts-init/services/checkdigit/openapi-cli/v1/swagger.ts @@ -0,0 +1,295 @@ +/* c8 ignore start */ +// services/checkdigit/openapi-cli/v1/swagger.ts +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ + +export interface InboundContext { + get(key: string): string; +} + +export interface ApiResponseContext { + status: number; + body?: unknown; + headers?: Record; +} + +export interface KoaResponseContext { + status: number; + body?: unknown; + headers?: Record; + set(key: string, value: string): void; +} + +export type LowercaseKeys = { + [K in keyof T as Lowercase]: T[K]; +}; + +type NotErrorInstanceLike = { stack?: never; cause?: never }; + +export interface Conflict {} + +/** + * Server error message + */ +export interface Error extends NotErrorInstanceLike { + message?: string; + code: + | 'INVALID_AT' + | 'INVALID_FROM' + | 'INVALID_TO' + | 'TO_LESS_THAN_FROM' + | 'HASH_MISMATCH' + | 'INVALID_SCHEMA' + | 'SCHEMA_VALIDATION_FAILURE' + | 'INVALID_JSON' + | 'INVALID_JSON_OBJECT' + | 'INVALID_ENCRYPTED_DATA' + | 'INVALID_KEY' + | 'KEY_MISMATCH' + | 'INVALID_PUBLIC_KEY_ID' + | 'INVALID_IF_MATCH' + | 'INVALID_CREATED_ON' + | 'INVALID_TENANT_ID' + | 'INVALID_ID'; +} +export interface Ping { + /** + * Current server time + */ + serverTime: string; // date-time +} +export interface PingGetContext { + request: PingGetRequestType; + response: PingGetResponseContext; +} +export interface PingGetRequestContext { + request: PingGetRequestType; +} +export interface PingGetRequestType extends InboundContext {} +export type PingGetResponseContext = PingGetResponseOK | PingGetResponseDefault; +export interface PingGetResponseDefault { + status: + | 100 + | 101 + | 102 + | 103 + | 201 + | 202 + | 203 + | 204 + | 205 + | 206 + | 207 + | 300 + | 301 + | 302 + | 303 + | 304 + | 305 + | 307 + | 308 + | 400 + | 401 + | 402 + | 403 + | 404 + | 405 + | 406 + | 407 + | 408 + | 409 + | 410 + | 411 + | 412 + | 413 + | 414 + | 415 + | 416 + | 417 + | 418 + | 419 + | 420 + | 421 + | 422 + | 423 + | 424 + | 426 + | 428 + | 429 + | 431 + | 451 + | 500 + | 501 + | 502 + | 503 + | 504 + | 505 + | 507 + | 511; + body: /* Server error message */ Error; +} +export interface PingGetResponseOK { + status: 200; + headers: LowercaseKeys; + body: Ping; +} +export interface PingGetResponseOKHeaders { + 'Created-On': string; // date-time +} +/** + * Public key in PEM format + */ +export type PublicKey = string; +/** + * Request to generate an RSA key-pair + */ +export interface RSAKeyPairRequest { + transmissionKey: /* Public key in PEM format */ PublicKey; +} +/** + * Encrypted RSA key-pair + */ +export interface RSAKeyPairResponse { + /** + * AES-256-CBC secret key data encrypted with the request tranmissionKey, encoded using base-64 + */ + transmissionSecretKey: string; + publicKey: /* Public key in PEM format */ PublicKey; + /** + * Private key part of generated RSA key pair, encrypted with transmissionSecretKey and base-64 encoded + */ + encryptedPrivateKey: string; +} +export interface RsaKeyPairPutContext { + request: RsaKeyPairPutRequestType; + params: { + tenantId: string; + rsaKeyPairId: string; + }; + response: RsaKeyPairPutResponseContext; +} +export interface RsaKeyPairPutRequestContext { + request: RsaKeyPairPutRequestType; + params: { + tenantId: string; + rsaKeyPairId: string; + }; +} +export interface RsaKeyPairPutRequestHeaders { + 'Created-On'?: string; // date-time +} +export interface RsaKeyPairPutRequestType extends InboundContext { + headers?: LowercaseKeys; + body?: /* Request to generate an RSA key-pair */ RSAKeyPairRequest; +} +export interface RsaKeyPairPutResponseConflict { + status: 409; +} +export type RsaKeyPairPutResponseContext = + | RsaKeyPairPutResponseOK + | RsaKeyPairPutResponseConflict + | RsaKeyPairPutResponseDefault; +export interface RsaKeyPairPutResponseDefault { + status: + | 100 + | 101 + | 102 + | 103 + | 201 + | 202 + | 203 + | 204 + | 205 + | 206 + | 207 + | 300 + | 301 + | 302 + | 303 + | 304 + | 305 + | 307 + | 308 + | 400 + | 401 + | 402 + | 403 + | 404 + | 405 + | 406 + | 407 + | 408 + | 410 + | 411 + | 412 + | 413 + | 414 + | 415 + | 416 + | 417 + | 418 + | 419 + | 420 + | 421 + | 422 + | 423 + | 424 + | 426 + | 428 + | 429 + | 431 + | 451 + | 500 + | 501 + | 502 + | 503 + | 504 + | 505 + | 507 + | 511; + body: /* Server error message */ Error; +} +export interface RsaKeyPairPutResponseOK { + status: 200; + headers: LowercaseKeys; + body: /* Encrypted RSA key-pair */ RSAKeyPairResponse; +} +export interface RsaKeyPairPutResponseOKHeaders { + 'Created-On': string; // date-time + 'Updated-On'?: string; // date-time +} + +export function setResponse( + response: Response, + responseContext: ResponseContext, +): void { + const koaResponse = response as unknown as KoaResponseContext; + koaResponse.status = responseContext.status; + if (responseContext.body) { + if (responseContext.body instanceof globalThis.Error) { + console.warn( + `Error instance or complex object with non-enumerable properties should not be used as response body directly because it'll cause firehose data inconsistency. Please use plain object literal instead.`, + ); + + const error = responseContext.body as unknown as Error; + const errorProperties: (keyof Error)[] = ['message', 'code']; + const convertedError = Object.fromEntries( + errorProperties + .map((key) => (error[key] === undefined ? undefined : [key, error[key]])) + .filter((entry) => entry !== undefined) as [string, unknown][], + ); + console.warn('The provided Error instance has been converted as a plain object:', convertedError); + koaResponse.body = convertedError; + } else { + koaResponse.body = responseContext.body; + } + } + if (responseContext.headers) { + for (const [key, value] of Object.entries(responseContext.headers)) { + koaResponse.set(key, value); + } + } +} + +/* eslint-enable */ +/* c8 ignore stop */ diff --git a/ts-init/services/checkdigit/openapi-cli/v1/swagger.yml b/ts-init/services/checkdigit/openapi-cli/v1/swagger.yml new file mode 100644 index 0000000..66ca70c --- /dev/null +++ b/ts-init/services/checkdigit/openapi-cli/v1/swagger.yml @@ -0,0 +1,209 @@ +openapi: 3.0.0 +info: + title: Sample Service + description: Sample Service + version: 0.0.1 + contact: + name: Check Digit +tags: + - name: API + - name: Health Check + +servers: + - url: /sample/v1 + +x-firehose-logged: false + +paths: + /ping: + get: + description: Test service connectivity + operationId: ping-get + tags: + - Health Check + responses: + '200': + $ref: '#/components/responses/Ping' + default: + $ref: '#/components/responses/ServerError' + + /tenant/{tenantId}/rsa-key-pair/{rsaKeyPairId}: + put: + x-firehose-logged: true + description: Generate a RSA 2048 public/private key pair. This operation will always return the same key pair for the given rsaKeyPairId. The passphrase for privateKey is set to the rsaKeyPairId. + operationId: rsa-key-pair-put + tags: + - API + parameters: + - $ref: '#/components/parameters/tenantId' + - $ref: '#/components/parameters/rsaKeyPairId' + - $ref: '#/components/parameters/createdOn' + requestBody: + $ref: '#/components/requestBodies/RSAKeyPairRequest' + responses: + '200': + $ref: '#/components/responses/RSAKeyPairResponse' + '409': + $ref: '#/components/responses/Conflict' + default: + $ref: '#/components/responses/ServerError' + +components: + schemas: + Ping: + type: object + additionalProperties: false + required: + - serverTime + properties: + serverTime: + type: string + format: date-time + description: Current server time + + Error: + type: object + additionalProperties: false + description: Server error message + required: + - code + properties: + message: + type: string + code: + type: string + enum: + - INVALID_AT + - INVALID_FROM + - INVALID_TO + - TO_LESS_THAN_FROM + - HASH_MISMATCH + - INVALID_SCHEMA + - SCHEMA_VALIDATION_FAILURE + - INVALID_JSON + - INVALID_JSON_OBJECT + - INVALID_ENCRYPTED_DATA + - INVALID_KEY + - KEY_MISMATCH + - INVALID_PUBLIC_KEY_ID + - INVALID_IF_MATCH + - INVALID_CREATED_ON + - INVALID_TENANT_ID + - INVALID_ID + + PublicKey: + description: Public key in PEM format + type: string + + RSAKeyPairRequest: + type: object + additionalProperties: false + description: Request to generate an RSA key-pair + required: + - transmissionKey + properties: + transmissionKey: + $ref: '#/components/schemas/PublicKey' + + RSAKeyPairResponse: + type: object + additionalProperties: false + description: Encrypted RSA key-pair + required: + - publicKey + - encryptedPrivateKey + - transmissionSecretKey + properties: + transmissionSecretKey: + description: AES-256-CBC secret key data encrypted with the request tranmissionKey, encoded using base-64 + type: string + publicKey: + $ref: '#/components/schemas/PublicKey' + encryptedPrivateKey: + description: Private key part of generated RSA key pair, encrypted with transmissionSecretKey and base-64 encoded + type: string + + parameters: + tenantId: + in: path + name: tenantId + description: Specifies tenant of Sentinel service + required: true + schema: + type: string + + rsaKeyPairId: + in: path + name: rsaKeyPairId + description: Parameter used to represent a generated RSA key pair + required: true + schema: + type: string + + createdOn: + name: Created-On + in: header + description: Created-On header + required: false + schema: + type: string + format: date-time + + requestBodies: + RSAKeyPairRequest: + description: Request to generate an RSA key-pair + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/RSAKeyPairRequest' + + responses: + RSAKeyPairResponse: + description: Encrypted RSA key-pair + content: + application/json: + schema: + $ref: '#/components/schemas/RSAKeyPairResponse' + headers: + Created-On: + required: true + $ref: '#/components/headers/Created-On' + Updated-On: + required: false + $ref: '#/components/headers/Updated-On' + + Ping: + description: ping successful response + headers: + Created-On: + $ref: '#/components/headers/Created-On' + content: + application/json: + schema: + $ref: '#/components/schemas/Ping' + + Conflict: + description: Conflict + + ServerError: + description: Server Error response + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + headers: + Created-On: + description: CreatedOn timestamp for the newly created record + required: true + schema: + type: string + format: date-time + + Updated-On: + description: UpdatedOn timestamp for the existing record + required: false + schema: + type: string + format: date-time diff --git a/ts-init/services/checkdigit/ping/v1/index.ts b/ts-init/services/checkdigit/ping/v1/index.ts new file mode 100644 index 0000000..417331a --- /dev/null +++ b/ts-init/services/checkdigit/ping/v1/index.ts @@ -0,0 +1,6 @@ +/* c8 ignore start */ +// services/checkdigit/ping/v1/index.ts + +export type * as pingV1 from './swagger'; + +/* c8 ignore stop */ diff --git a/ts-init/services/checkdigit/ping/v1/interface.d.ts b/ts-init/services/checkdigit/ping/v1/interface.d.ts new file mode 100644 index 0000000..f90374f --- /dev/null +++ b/ts-init/services/checkdigit/ping/v1/interface.d.ts @@ -0,0 +1,28 @@ +/* c8 ignore start */ +// services/checkdigit/ping/v1/interface.d.ts + +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ + +import type { MappedRequestBody, MappedRequestHeaders, MappedResponse, CaseInsensitive } from '../../../index'; +import type * as types from './swagger'; + +declare global { + function fetch( + url: `https://ping.checkdigit/ping/v1/ping`, + init?: Omit & { + method?: CaseInsensitive<'GET'>; + } & MappedRequestHeaders<{} | undefined>, + ): Promise>; + + function fetch( + url: `https://ping.checkdigit/ping/v1/ping`, + init: Omit & { + method: CaseInsensitive<'HEAD'>; + } & MappedRequestHeaders<{} | undefined>, + ): Promise>; +} + +/* eslint-enable */ + +/* c8 ignore stop */ diff --git a/ts-init/services/checkdigit/ping/v1/swagger.ts b/ts-init/services/checkdigit/ping/v1/swagger.ts new file mode 100644 index 0000000..2a18d65 --- /dev/null +++ b/ts-init/services/checkdigit/ping/v1/swagger.ts @@ -0,0 +1,223 @@ +/* c8 ignore start */ +// services/checkdigit/ping/v1/swagger.ts +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ + +export interface InboundContext { + get(key: string): string; +} + +export interface ApiResponseContext { + status: number; + body?: unknown; + headers?: Record; +} + +export interface KoaResponseContext { + status: number; + body?: unknown; + headers?: Record; + set(key: string, value: string): void; +} + +export type LowercaseKeys = { + [K in keyof T as Lowercase]: T[K]; +}; + +type NotErrorInstanceLike = { stack?: never; cause?: never }; + +/** + * Error message + */ +export interface Error extends NotErrorInstanceLike { + message?: string; + code?: string; +} +export interface Ping { + /** + * Current server time + */ + serverTime: string; // date-time +} +export interface PingGetContext { + request: PingGetRequestType; + response: PingGetResponseContext; +} +export interface PingGetRequestContext { + request: PingGetRequestType; +} +export interface PingGetRequestType extends InboundContext {} +export type PingGetResponseContext = PingGetResponseOK | PingGetResponseDefault; +export interface PingGetResponseDefault { + status: + | 100 + | 101 + | 102 + | 103 + | 201 + | 202 + | 203 + | 204 + | 205 + | 206 + | 207 + | 300 + | 301 + | 302 + | 303 + | 304 + | 305 + | 307 + | 308 + | 400 + | 401 + | 402 + | 403 + | 404 + | 405 + | 406 + | 407 + | 408 + | 409 + | 410 + | 411 + | 412 + | 413 + | 414 + | 415 + | 416 + | 417 + | 418 + | 419 + | 420 + | 421 + | 422 + | 423 + | 424 + | 426 + | 428 + | 429 + | 431 + | 451 + | 500 + | 501 + | 502 + | 503 + | 504 + | 505 + | 507 + | 511; + body: /* Error message */ Error; +} +export interface PingGetResponseOK { + status: 200; + body: Ping; +} +export interface PingHeadContext { + request: PingHeadRequestType; + response: PingHeadResponseContext; +} +export interface PingHeadRequestContext { + request: PingHeadRequestType; +} +export interface PingHeadRequestType extends InboundContext {} +export type PingHeadResponseContext = PingHeadResponseOK | PingHeadResponseDefault; +export interface PingHeadResponseDefault { + status: + | 100 + | 101 + | 102 + | 103 + | 201 + | 202 + | 203 + | 204 + | 205 + | 206 + | 207 + | 300 + | 301 + | 302 + | 303 + | 304 + | 305 + | 307 + | 308 + | 400 + | 401 + | 402 + | 403 + | 404 + | 405 + | 406 + | 407 + | 408 + | 409 + | 410 + | 411 + | 412 + | 413 + | 414 + | 415 + | 416 + | 417 + | 418 + | 419 + | 420 + | 421 + | 422 + | 423 + | 424 + | 426 + | 428 + | 429 + | 431 + | 451 + | 500 + | 501 + | 502 + | 503 + | 504 + | 505 + | 507 + | 511; + body: /* Error message */ Error; +} +export interface PingHeadResponseOK { + status: 200; +} + +export function setResponse( + response: Response, + responseContext: ResponseContext, +): void { + const koaResponse = response as unknown as KoaResponseContext; + koaResponse.status = responseContext.status; + if (responseContext.body) { + if (responseContext.body instanceof globalThis.Error) { + console.warn( + `Error instance or complex object with non-enumerable properties should not be used as response body directly because it'll cause firehose data inconsistency. Please use plain object literal instead.`, + ); + + const error = responseContext.body as unknown as Error; + const errorProperties: (keyof Error)[] = ['message', 'code']; + const convertedError = Object.fromEntries( + errorProperties + .map((key) => (error[key] === undefined ? undefined : [key, error[key]])) + .filter((entry) => entry !== undefined) as [string, unknown][], + ); + console.warn('The provided Error instance has been converted as a plain object:', convertedError); + koaResponse.body = convertedError; + } else { + koaResponse.body = responseContext.body; + } + } + if (responseContext.headers) { + for (const [key, value] of Object.entries(responseContext.headers)) { + koaResponse.set(key, value); + } + } +} + +/* eslint-enable */ +/* c8 ignore stop */ diff --git a/ts-init/services/checkdigit/ping/v1/swagger.yml b/ts-init/services/checkdigit/ping/v1/swagger.yml new file mode 100644 index 0000000..07b199b --- /dev/null +++ b/ts-init/services/checkdigit/ping/v1/swagger.yml @@ -0,0 +1,81 @@ +openapi: 3.0.0 +info: + title: Ping Service + description: | + A service that accepts incoming pings. This is only used for testing. + + © Check Digit LLC. 2019-2024 + version: 1.0.0 + contact: + name: Check Digit +servers: + - url: /ping/v1 + +tags: + - name: Service Health + +x-firehoseLogged: false + +paths: + /ping: + get: + description: Test service connectivity + tags: + - Service Health + operationId: ping-get + responses: + '200': + $ref: '#/components/responses/Ping' + default: + $ref: '#/components/responses/ServerError' + head: + description: Test service connectivity + tags: + - Service Health + operationId: ping-head + responses: + '200': + description: ping HEAD successful + content: + application/json: + schema: {} + default: + $ref: '#/components/responses/ServerError' + +components: + schemas: + Ping: + type: object + additionalProperties: false + required: + - serverTime + properties: + serverTime: + type: string + format: date-time + description: Current server time + + Error: + type: object + additionalProperties: false + description: Error message + properties: + message: + type: string + code: + type: string + + responses: + ServerError: + description: Server Error response + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + Ping: + description: ping successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Ping' diff --git a/ts-init/services/checkdigit/ping/v2/index.ts b/ts-init/services/checkdigit/ping/v2/index.ts new file mode 100644 index 0000000..0216e3a --- /dev/null +++ b/ts-init/services/checkdigit/ping/v2/index.ts @@ -0,0 +1,6 @@ +/* c8 ignore start */ +// services/checkdigit/ping/v2/index.ts + +export type * as pingV2 from './swagger'; + +/* c8 ignore stop */ diff --git a/ts-init/services/checkdigit/ping/v2/interface.d.ts b/ts-init/services/checkdigit/ping/v2/interface.d.ts new file mode 100644 index 0000000..8a84cc4 --- /dev/null +++ b/ts-init/services/checkdigit/ping/v2/interface.d.ts @@ -0,0 +1,28 @@ +/* c8 ignore start */ +// services/checkdigit/ping/v2/interface.d.ts + +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ + +import type { MappedRequestBody, MappedRequestHeaders, MappedResponse, CaseInsensitive } from '../../../index'; +import type * as types from './swagger'; + +declare global { + function fetch( + url: `https://ping.checkdigit/ping/v2/ping`, + init?: Omit & { + method?: CaseInsensitive<'GET'>; + } & MappedRequestHeaders<{} | undefined>, + ): Promise>; + + function fetch( + url: `https://ping.checkdigit/ping/v2/ping`, + init: Omit & { + method: CaseInsensitive<'HEAD'>; + } & MappedRequestHeaders<{} | undefined>, + ): Promise>; +} + +/* eslint-enable */ + +/* c8 ignore stop */ diff --git a/ts-init/services/checkdigit/ping/v2/swagger.ts b/ts-init/services/checkdigit/ping/v2/swagger.ts new file mode 100644 index 0000000..c70ece6 --- /dev/null +++ b/ts-init/services/checkdigit/ping/v2/swagger.ts @@ -0,0 +1,223 @@ +/* c8 ignore start */ +// services/checkdigit/ping/v2/swagger.ts +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ + +export interface InboundContext { + get(key: string): string; +} + +export interface ApiResponseContext { + status: number; + body?: unknown; + headers?: Record; +} + +export interface KoaResponseContext { + status: number; + body?: unknown; + headers?: Record; + set(key: string, value: string): void; +} + +export type LowercaseKeys = { + [K in keyof T as Lowercase]: T[K]; +}; + +type NotErrorInstanceLike = { stack?: never; cause?: never }; + +/** + * Error message + */ +export interface Error extends NotErrorInstanceLike { + message?: string; + code?: string; +} +export interface Ping { + /** + * Current server time + */ + serverTime: string; // date-time +} +export interface PingGetContext { + request: PingGetRequestType; + response: PingGetResponseContext; +} +export interface PingGetRequestContext { + request: PingGetRequestType; +} +export interface PingGetRequestType extends InboundContext {} +export type PingGetResponseContext = PingGetResponseOK | PingGetResponseDefault; +export interface PingGetResponseDefault { + status: + | 100 + | 101 + | 102 + | 103 + | 201 + | 202 + | 203 + | 204 + | 205 + | 206 + | 207 + | 300 + | 301 + | 302 + | 303 + | 304 + | 305 + | 307 + | 308 + | 400 + | 401 + | 402 + | 403 + | 404 + | 405 + | 406 + | 407 + | 408 + | 409 + | 410 + | 411 + | 412 + | 413 + | 414 + | 415 + | 416 + | 417 + | 418 + | 419 + | 420 + | 421 + | 422 + | 423 + | 424 + | 426 + | 428 + | 429 + | 431 + | 451 + | 500 + | 501 + | 502 + | 503 + | 504 + | 505 + | 507 + | 511; + body: /* Error message */ Error; +} +export interface PingGetResponseOK { + status: 200; + body: Ping; +} +export interface PingHeadContext { + request: PingHeadRequestType; + response: PingHeadResponseContext; +} +export interface PingHeadRequestContext { + request: PingHeadRequestType; +} +export interface PingHeadRequestType extends InboundContext {} +export type PingHeadResponseContext = PingHeadResponseOK | PingHeadResponseDefault; +export interface PingHeadResponseDefault { + status: + | 100 + | 101 + | 102 + | 103 + | 201 + | 202 + | 203 + | 204 + | 205 + | 206 + | 207 + | 300 + | 301 + | 302 + | 303 + | 304 + | 305 + | 307 + | 308 + | 400 + | 401 + | 402 + | 403 + | 404 + | 405 + | 406 + | 407 + | 408 + | 409 + | 410 + | 411 + | 412 + | 413 + | 414 + | 415 + | 416 + | 417 + | 418 + | 419 + | 420 + | 421 + | 422 + | 423 + | 424 + | 426 + | 428 + | 429 + | 431 + | 451 + | 500 + | 501 + | 502 + | 503 + | 504 + | 505 + | 507 + | 511; + body: /* Error message */ Error; +} +export interface PingHeadResponseOK { + status: 200; +} + +export function setResponse( + response: Response, + responseContext: ResponseContext, +): void { + const koaResponse = response as unknown as KoaResponseContext; + koaResponse.status = responseContext.status; + if (responseContext.body) { + if (responseContext.body instanceof globalThis.Error) { + console.warn( + `Error instance or complex object with non-enumerable properties should not be used as response body directly because it'll cause firehose data inconsistency. Please use plain object literal instead.`, + ); + + const error = responseContext.body as unknown as Error; + const errorProperties: (keyof Error)[] = ['message', 'code']; + const convertedError = Object.fromEntries( + errorProperties + .map((key) => (error[key] === undefined ? undefined : [key, error[key]])) + .filter((entry) => entry !== undefined) as [string, unknown][], + ); + console.warn('The provided Error instance has been converted as a plain object:', convertedError); + koaResponse.body = convertedError; + } else { + koaResponse.body = responseContext.body; + } + } + if (responseContext.headers) { + for (const [key, value] of Object.entries(responseContext.headers)) { + koaResponse.set(key, value); + } + } +} + +/* eslint-enable */ +/* c8 ignore stop */ diff --git a/ts-init/services/checkdigit/ping/v2/swagger.yml b/ts-init/services/checkdigit/ping/v2/swagger.yml new file mode 100644 index 0000000..c9b6a5c --- /dev/null +++ b/ts-init/services/checkdigit/ping/v2/swagger.yml @@ -0,0 +1,81 @@ +openapi: 3.0.0 +info: + title: Ping Service + description: | + A service that accepts incoming pings. This is only used for testing. + + © Check Digit LLC. 2019-2024 + version: 2.0.6 + contact: + name: Check Digit +servers: + - url: /ping/v2 + +tags: + - name: Service Health + +x-firehose-logged: false + +paths: + /ping: + get: + description: Test service connectivity + tags: + - Service Health + operationId: ping-get + responses: + '200': + $ref: '#/components/responses/Ping' + default: + $ref: '#/components/responses/ServerError' + head: + description: Test service connectivity + tags: + - Service Health + operationId: ping-head + responses: + '200': + description: ping HEAD successful + content: + application/json: + schema: {} + default: + $ref: '#/components/responses/ServerError' + +components: + schemas: + Ping: + type: object + additionalProperties: false + required: + - serverTime + properties: + serverTime: + type: string + format: date-time + description: Current server time + + Error: + type: object + additionalProperties: false + description: Error message + properties: + message: + type: string + code: + type: string + + responses: + ServerError: + description: Server Error response + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + Ping: + description: ping successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Ping' diff --git a/ts-init/services/index-fixture.ts b/ts-init/services/index-fixture.ts new file mode 100644 index 0000000..1f8e9a2 --- /dev/null +++ b/ts-init/services/index-fixture.ts @@ -0,0 +1,67 @@ +/* c8 ignore start */ +// services/index-fixture.ts + +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ + +import type { pingApi } from './ping'; +import type { openapiCliApi } from './openapiCli'; + +export interface InboundContext { + get: (field: string) => string; +} +type FunctionFromRecord = (parameter: K) => T[K]; + +type MappedResponseHeaders = undefined extends HeadersType + ? { + header: NonNullable; + headers: NonNullable; + get: FunctionFromRecord>; + } + : { + header: HeadersType; + headers: HeadersType; + get: FunctionFromRecord; + }; + +type MappedResponseBody = undefined extends BodyType + ? { + body: NonNullable; + } + : { + body: BodyType; + }; + +export interface ApiResponseContext { + status: number; + body?: unknown; + headers?: Record; +} + +/** + * Adding backward compatibility using mapped type + * The existing codes might access certain properties available in Koa's Response which may be unavailable in ResponseContext (e.g. 'statusCode' vs. 'status') + * The following mapped type create alias between the Koa's Response and ResponseContext. + * More importantly it maintain both 'status' and 'statusCode' as discriminators to differentiate each corresponding ResponseContexts in the union-ed ResponseContext at the api operation level + */ +export type MappedResponse = ResponseContextUnion extends infer ResponseContext + ? ResponseContext extends ApiResponseContext + ? { + status: ResponseContext['status']; + statusCode: ResponseContext['status']; + } & MappedResponseHeaders & + MappedResponseBody + : never + : never; + +export interface TypedServices { + ping: (context: InboundContext) => pingApi; + _main: openapiCliApi; +} + +export type * from './ping'; +export type * from './openapiCli'; + +/* eslint-enable */ + +/* c8 ignore stop */ diff --git a/ts-init/services/index.ts b/ts-init/services/index.ts new file mode 100644 index 0000000..0c49b91 --- /dev/null +++ b/ts-init/services/index.ts @@ -0,0 +1,54 @@ +/* c8 ignore start */ +// services/index.ts + +export type Stringified = string & { _: T }; + +interface ApiResponseContext { + status: number; + body?: unknown; + headers?: Record; +} + +type ExtensionHeaders = Record<`x-${string}`, string> & { + 'content-type'?: 'application/json'; +}; + +export type MappedRequestHeaders = undefined extends HeadersType + ? { + headers?: ExtensionHeaders & NonNullable; + } + : { + headers: ExtensionHeaders & HeadersType; + }; + +export type MappedRequestBody = undefined extends BodyType + ? { + body?: Stringified>; + } + : { + body: Stringified; + }; + +type HeaderGetter> = Omit & { + get(headerName: HeaderName): HeadersType[HeaderName]; +}; + +export type MappedResponse = ResponseContextUnion extends infer ResponseContext + ? ResponseContext extends ApiResponseContext + ? Omit & { + readonly status: ResponseContext['status']; + readonly headers: HeaderGetter>; + json(): Promise; + } + : never + : never; + +export type CaseInsensitive = T extends `${infer First}${infer Rest}` + ? `${Uppercase}${CaseInsensitive}` | `${Lowercase}${CaseInsensitive}` + : ''; + +export type * from './checkdigit/ping/v2'; +export type * from './checkdigit/ping/v1'; +export type * from './checkdigit/openapi-cli/v1'; + +/* c8 ignore stop */ diff --git a/ts-init/services/openapiCli/index.ts b/ts-init/services/openapiCli/index.ts new file mode 100644 index 0000000..f4e17b8 --- /dev/null +++ b/ts-init/services/openapiCli/index.ts @@ -0,0 +1,15 @@ +/* c8 ignore start */ +// services/openapiCli/index.ts + +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ + +import type * as v1 from './v1/index'; + +export type openapiCliApi = v1.Api; + +export type * as openapiCliV1 from './v1/index'; + +/* eslint-enable */ + +/* c8 ignore stop */ diff --git a/ts-init/services/openapiCli/v1/index.ts b/ts-init/services/openapiCli/v1/index.ts new file mode 100644 index 0000000..64d7754 --- /dev/null +++ b/ts-init/services/openapiCli/v1/index.ts @@ -0,0 +1,5 @@ +/* c8 ignore start */ +// services/openapiCli/v1/index.ts +export type * from './interface'; + +/* c8 ignore stop */ diff --git a/ts-init/services/openapiCli/v1/interface.ts b/ts-init/services/openapiCli/v1/interface.ts new file mode 100644 index 0000000..9dca985 --- /dev/null +++ b/ts-init/services/openapiCli/v1/interface.ts @@ -0,0 +1,60 @@ +/* c8 ignore start */ +// services/openapiCli/v1/interface.ts + +import type * as types from './swagger'; +import type { MappedResponse } from '../../index-fixture.ts'; + +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ + +export interface RsaKeyPairPutTest + extends Promise> { + send(body: types.RsaKeyPairPutRequestType['body']): this; + set(headerName: keyof NonNullable, value: string): this; + set(headers: Partial): this; + + expect( + status: Status, + ): Status extends types.RsaKeyPairPutResponseOK['status'] + ? RsaKeyPairPutTest + : Status extends types.RsaKeyPairPutResponseConflict['status'] + ? RsaKeyPairPutTest + : Status extends types.RsaKeyPairPutResponseDefault['status'] + ? RsaKeyPairPutTest + : never; + expect(checker: (res: Awaited) => any): this; + expect( + headerName: ResponseContext extends { headers: unknown } ? keyof ResponseContext['headers'] : never, + headerValue: string | RegExp, + ): this; + expect(body: string | RegExp | Object): this; +} +export interface PingGetTest + extends Promise> { + set(headerName: string, value: string): this; + set(headers: Record): this; + + expect( + status: Status, + ): Status extends types.PingGetResponseOK['status'] + ? PingGetTest + : Status extends types.PingGetResponseDefault['status'] + ? PingGetTest + : never; + expect(checker: (res: Awaited) => any): this; + expect( + headerName: ResponseContext extends { headers: unknown } ? keyof ResponseContext['headers'] : never, + headerValue: string | RegExp, + ): this; + expect(body: string | RegExp | Object): this; +} + +export interface Api { + put(url: `/sample/v1/tenant/${string}/rsa-key-pair/${string}`): RsaKeyPairPutTest; + + get(url: `/sample/v1/ping`): PingGetTest; +} + +/* eslint-enable */ + +/* c8 ignore stop */ diff --git a/ts-init/services/openapiCli/v1/swagger.ts b/ts-init/services/openapiCli/v1/swagger.ts new file mode 100644 index 0000000..e20b220 --- /dev/null +++ b/ts-init/services/openapiCli/v1/swagger.ts @@ -0,0 +1,295 @@ +/* c8 ignore start */ +// api/v1/swagger.ts +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ + +export interface InboundContext { + get(key: string): string; +} + +export interface ApiResponseContext { + status: number; + body?: unknown; + headers?: Record; +} + +export interface KoaResponseContext { + status: number; + body?: unknown; + headers?: Record; + set(key: string, value: string): void; +} + +export type LowercaseKeys = { + [K in keyof T as Lowercase]: T[K]; +}; + +type NotErrorInstanceLike = { stack?: never; cause?: never }; + +export interface Conflict {} + +/** + * Server error message + */ +export interface Error extends NotErrorInstanceLike { + message?: string; + code: + | 'INVALID_AT' + | 'INVALID_FROM' + | 'INVALID_TO' + | 'TO_LESS_THAN_FROM' + | 'HASH_MISMATCH' + | 'INVALID_SCHEMA' + | 'SCHEMA_VALIDATION_FAILURE' + | 'INVALID_JSON' + | 'INVALID_JSON_OBJECT' + | 'INVALID_ENCRYPTED_DATA' + | 'INVALID_KEY' + | 'KEY_MISMATCH' + | 'INVALID_PUBLIC_KEY_ID' + | 'INVALID_IF_MATCH' + | 'INVALID_CREATED_ON' + | 'INVALID_TENANT_ID' + | 'INVALID_ID'; +} +export interface Ping { + /** + * Current server time + */ + serverTime: string; // date-time +} +export interface PingGetContext { + request: PingGetRequestType; + response: PingGetResponseContext; +} +export interface PingGetRequestContext { + request: PingGetRequestType; +} +export interface PingGetRequestType extends InboundContext {} +export type PingGetResponseContext = PingGetResponseOK | PingGetResponseDefault; +export interface PingGetResponseDefault { + status: + | 100 + | 101 + | 102 + | 103 + | 201 + | 202 + | 203 + | 204 + | 205 + | 206 + | 207 + | 300 + | 301 + | 302 + | 303 + | 304 + | 305 + | 307 + | 308 + | 400 + | 401 + | 402 + | 403 + | 404 + | 405 + | 406 + | 407 + | 408 + | 409 + | 410 + | 411 + | 412 + | 413 + | 414 + | 415 + | 416 + | 417 + | 418 + | 419 + | 420 + | 421 + | 422 + | 423 + | 424 + | 426 + | 428 + | 429 + | 431 + | 451 + | 500 + | 501 + | 502 + | 503 + | 504 + | 505 + | 507 + | 511; + body: /* Server error message */ Error; +} +export interface PingGetResponseOK { + status: 200; + headers: LowercaseKeys; + body: Ping; +} +export interface PingGetResponseOKHeaders { + 'Created-On': string; // date-time +} +/** + * Public key in PEM format + */ +export type PublicKey = string; +/** + * Request to generate an RSA key-pair + */ +export interface RSAKeyPairRequest { + transmissionKey: /* Public key in PEM format */ PublicKey; +} +/** + * Encrypted RSA key-pair + */ +export interface RSAKeyPairResponse { + /** + * AES-256-CBC secret key data encrypted with the request tranmissionKey, encoded using base-64 + */ + transmissionSecretKey: string; + publicKey: /* Public key in PEM format */ PublicKey; + /** + * Private key part of generated RSA key pair, encrypted with transmissionSecretKey and base-64 encoded + */ + encryptedPrivateKey: string; +} +export interface RsaKeyPairPutContext { + request: RsaKeyPairPutRequestType; + params: { + tenantId: string; + rsaKeyPairId: string; + }; + response: RsaKeyPairPutResponseContext; +} +export interface RsaKeyPairPutRequestContext { + request: RsaKeyPairPutRequestType; + params: { + tenantId: string; + rsaKeyPairId: string; + }; +} +export interface RsaKeyPairPutRequestHeaders { + 'Created-On'?: string; // date-time +} +export interface RsaKeyPairPutRequestType extends InboundContext { + headers?: LowercaseKeys; + body?: /* Request to generate an RSA key-pair */ RSAKeyPairRequest; +} +export interface RsaKeyPairPutResponseConflict { + status: 409; +} +export type RsaKeyPairPutResponseContext = + | RsaKeyPairPutResponseOK + | RsaKeyPairPutResponseConflict + | RsaKeyPairPutResponseDefault; +export interface RsaKeyPairPutResponseDefault { + status: + | 100 + | 101 + | 102 + | 103 + | 201 + | 202 + | 203 + | 204 + | 205 + | 206 + | 207 + | 300 + | 301 + | 302 + | 303 + | 304 + | 305 + | 307 + | 308 + | 400 + | 401 + | 402 + | 403 + | 404 + | 405 + | 406 + | 407 + | 408 + | 410 + | 411 + | 412 + | 413 + | 414 + | 415 + | 416 + | 417 + | 418 + | 419 + | 420 + | 421 + | 422 + | 423 + | 424 + | 426 + | 428 + | 429 + | 431 + | 451 + | 500 + | 501 + | 502 + | 503 + | 504 + | 505 + | 507 + | 511; + body: /* Server error message */ Error; +} +export interface RsaKeyPairPutResponseOK { + status: 200; + headers: LowercaseKeys; + body: /* Encrypted RSA key-pair */ RSAKeyPairResponse; +} +export interface RsaKeyPairPutResponseOKHeaders { + 'Created-On': string; // date-time + 'Updated-On'?: string; // date-time +} + +export function setResponse( + response: Response, + responseContext: ResponseContext, +): void { + const koaResponse = response as unknown as KoaResponseContext; + koaResponse.status = responseContext.status; + if (responseContext.body) { + if (responseContext.body instanceof globalThis.Error) { + console.warn( + `Error instance or complex object with non-enumerable properties should not be used as response body directly because it'll cause firehose data inconsistency. Please use plain object literal instead.`, + ); + + const error = responseContext.body as unknown as Error; + const errorProperties: (keyof Error)[] = ['message', 'code']; + const convertedError = Object.fromEntries( + errorProperties + .map((key) => (error[key] === undefined ? undefined : [key, error[key]])) + .filter((entry) => entry !== undefined) as [string, unknown][], + ); + console.warn('The provided Error instance has been converted as a plain object:', convertedError); + koaResponse.body = convertedError; + } else { + koaResponse.body = responseContext.body; + } + } + if (responseContext.headers) { + for (const [key, value] of Object.entries(responseContext.headers)) { + koaResponse.set(key, value); + } + } +} + +/* eslint-enable */ +/* c8 ignore stop */ diff --git a/ts-init/services/ping/index.ts b/ts-init/services/ping/index.ts new file mode 100644 index 0000000..fe615ac --- /dev/null +++ b/ts-init/services/ping/index.ts @@ -0,0 +1,12 @@ +/* c8 ignore start */ +// services/ping/index.ts + +import type * as v1 from './v1'; +import type * as v2 from './v2'; + +export type pingApi = v1.Api & v2.Api; + +export type * as pingV1 from './v1'; +export type * as pingV2 from './v2'; + +/* c8 ignore stop */ diff --git a/ts-init/services/ping/v1/index.ts b/ts-init/services/ping/v1/index.ts new file mode 100644 index 0000000..aae2a92 --- /dev/null +++ b/ts-init/services/ping/v1/index.ts @@ -0,0 +1,6 @@ +/* c8 ignore start */ +// services/ping/v1/index.ts +export type * from './swagger'; +export type { Api } from './interface'; + +/* c8 ignore stop */ diff --git a/ts-init/services/ping/v1/interface.ts b/ts-init/services/ping/v1/interface.ts new file mode 100644 index 0000000..495c25f --- /dev/null +++ b/ts-init/services/ping/v1/interface.ts @@ -0,0 +1,50 @@ +/* c8 ignore start */ +// services/ping/v1/interface.ts + +import type { MappedResponse } from '../../index-fixture.ts'; +import type * as types from './swagger'; + +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ + +export interface Api { + get( + url: `/ping/v1/ping`, + + options?: { + resolveWithFullResponse: false; + headers?: Record; + }, + ): Promise; + + get( + url: `/ping/v1/ping`, + + options?: { + resolveWithFullResponse: true; + headers?: Record; + }, + ): Promise>; + + head( + url: `/ping/v1/ping`, + + options?: { + resolveWithFullResponse: false; + headers?: Record; + }, + ): Promise; + + head( + url: `/ping/v1/ping`, + + options?: { + resolveWithFullResponse: true; + headers?: Record; + }, + ): Promise>; +} + +/* eslint-enable */ + +/* c8 ignore stop */ diff --git a/ts-init/services/ping/v1/swagger.ts b/ts-init/services/ping/v1/swagger.ts new file mode 100644 index 0000000..f9cdb2d --- /dev/null +++ b/ts-init/services/ping/v1/swagger.ts @@ -0,0 +1,223 @@ +/* c8 ignore start */ +// services/ping/v1/swagger.ts +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ + +export interface InboundContext { + get(key: string): string; +} + +export interface ApiResponseContext { + status: number; + body?: unknown; + headers?: Record; +} + +export interface KoaResponseContext { + status: number; + body?: unknown; + headers?: Record; + set(key: string, value: string): void; +} + +export type LowercaseKeys = { + [K in keyof T as Lowercase]: T[K]; +}; + +type NotErrorInstanceLike = { stack?: never; cause?: never }; + +/** + * Error message + */ +export interface Error extends NotErrorInstanceLike { + message?: string; + code?: string; +} +export interface Ping { + /** + * Current server time + */ + serverTime: string; // date-time +} +export interface PingGetContext { + request: PingGetRequestType; + response: PingGetResponseContext; +} +export interface PingGetRequestContext { + request: PingGetRequestType; +} +export interface PingGetRequestType extends InboundContext {} +export type PingGetResponseContext = PingGetResponseOK | PingGetResponseDefault; +export interface PingGetResponseDefault { + status: + | 100 + | 101 + | 102 + | 103 + | 201 + | 202 + | 203 + | 204 + | 205 + | 206 + | 207 + | 300 + | 301 + | 302 + | 303 + | 304 + | 305 + | 307 + | 308 + | 400 + | 401 + | 402 + | 403 + | 404 + | 405 + | 406 + | 407 + | 408 + | 409 + | 410 + | 411 + | 412 + | 413 + | 414 + | 415 + | 416 + | 417 + | 418 + | 419 + | 420 + | 421 + | 422 + | 423 + | 424 + | 426 + | 428 + | 429 + | 431 + | 451 + | 500 + | 501 + | 502 + | 503 + | 504 + | 505 + | 507 + | 511; + body: /* Error message */ Error; +} +export interface PingGetResponseOK { + status: 200; + body: Ping; +} +export interface PingHeadContext { + request: PingHeadRequestType; + response: PingHeadResponseContext; +} +export interface PingHeadRequestContext { + request: PingHeadRequestType; +} +export interface PingHeadRequestType extends InboundContext {} +export type PingHeadResponseContext = PingHeadResponseOK | PingHeadResponseDefault; +export interface PingHeadResponseDefault { + status: + | 100 + | 101 + | 102 + | 103 + | 201 + | 202 + | 203 + | 204 + | 205 + | 206 + | 207 + | 300 + | 301 + | 302 + | 303 + | 304 + | 305 + | 307 + | 308 + | 400 + | 401 + | 402 + | 403 + | 404 + | 405 + | 406 + | 407 + | 408 + | 409 + | 410 + | 411 + | 412 + | 413 + | 414 + | 415 + | 416 + | 417 + | 418 + | 419 + | 420 + | 421 + | 422 + | 423 + | 424 + | 426 + | 428 + | 429 + | 431 + | 451 + | 500 + | 501 + | 502 + | 503 + | 504 + | 505 + | 507 + | 511; + body: /* Error message */ Error; +} +export interface PingHeadResponseOK { + status: 200; +} + +export function setResponse( + response: Response, + responseContext: ResponseContext, +): void { + const koaResponse = response as unknown as KoaResponseContext; + koaResponse.status = responseContext.status; + if (responseContext.body) { + if (responseContext.body instanceof globalThis.Error) { + console.warn( + `Error instance or complex object with non-enumerable properties should not be used as response body directly because it'll cause firehose data inconsistency. Please use plain object literal instead.`, + ); + + const error = responseContext.body as unknown as Error; + const errorProperties: (keyof Error)[] = ['message', 'code']; + const convertedError = Object.fromEntries( + errorProperties + .map((key) => (error[key] === undefined ? undefined : [key, error[key]])) + .filter((entry) => entry !== undefined) as [string, unknown][], + ); + console.warn('The provided Error instance has been converted as a plain object:', convertedError); + koaResponse.body = convertedError; + } else { + koaResponse.body = responseContext.body; + } + } + if (responseContext.headers) { + for (const [key, value] of Object.entries(responseContext.headers)) { + koaResponse.set(key, value); + } + } +} + +/* eslint-enable */ +/* c8 ignore stop */ diff --git a/ts-init/services/ping/v1/swagger.yml b/ts-init/services/ping/v1/swagger.yml new file mode 100644 index 0000000..07b199b --- /dev/null +++ b/ts-init/services/ping/v1/swagger.yml @@ -0,0 +1,81 @@ +openapi: 3.0.0 +info: + title: Ping Service + description: | + A service that accepts incoming pings. This is only used for testing. + + © Check Digit LLC. 2019-2024 + version: 1.0.0 + contact: + name: Check Digit +servers: + - url: /ping/v1 + +tags: + - name: Service Health + +x-firehoseLogged: false + +paths: + /ping: + get: + description: Test service connectivity + tags: + - Service Health + operationId: ping-get + responses: + '200': + $ref: '#/components/responses/Ping' + default: + $ref: '#/components/responses/ServerError' + head: + description: Test service connectivity + tags: + - Service Health + operationId: ping-head + responses: + '200': + description: ping HEAD successful + content: + application/json: + schema: {} + default: + $ref: '#/components/responses/ServerError' + +components: + schemas: + Ping: + type: object + additionalProperties: false + required: + - serverTime + properties: + serverTime: + type: string + format: date-time + description: Current server time + + Error: + type: object + additionalProperties: false + description: Error message + properties: + message: + type: string + code: + type: string + + responses: + ServerError: + description: Server Error response + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + Ping: + description: ping successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Ping' diff --git a/ts-init/services/ping/v2/index.ts b/ts-init/services/ping/v2/index.ts new file mode 100644 index 0000000..830bde6 --- /dev/null +++ b/ts-init/services/ping/v2/index.ts @@ -0,0 +1,6 @@ +/* c8 ignore start */ +// services/ping/v2/index.ts +export type * from './swagger'; +export type { Api } from './interface'; + +/* c8 ignore stop */ diff --git a/ts-init/services/ping/v2/interface.ts b/ts-init/services/ping/v2/interface.ts new file mode 100644 index 0000000..3cfc200 --- /dev/null +++ b/ts-init/services/ping/v2/interface.ts @@ -0,0 +1,50 @@ +/* c8 ignore start */ +// services/ping/v2/interface.ts + +import type { MappedResponse } from '../../index-fixture.ts'; +import type * as types from './swagger'; + +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ + +export interface Api { + get( + url: `/ping/v2/ping`, + + options?: { + resolveWithFullResponse: false; + headers?: Record; + }, + ): Promise; + + get( + url: `/ping/v2/ping`, + + options?: { + resolveWithFullResponse: true; + headers?: Record; + }, + ): Promise>; + + head( + url: `/ping/v2/ping`, + + options?: { + resolveWithFullResponse: false; + headers?: Record; + }, + ): Promise; + + head( + url: `/ping/v2/ping`, + + options?: { + resolveWithFullResponse: true; + headers?: Record; + }, + ): Promise>; +} + +/* eslint-enable */ + +/* c8 ignore stop */ diff --git a/ts-init/services/ping/v2/swagger.ts b/ts-init/services/ping/v2/swagger.ts new file mode 100644 index 0000000..f7e6ac0 --- /dev/null +++ b/ts-init/services/ping/v2/swagger.ts @@ -0,0 +1,223 @@ +/* c8 ignore start */ +// services/ping/v2/swagger.ts +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ + +export interface InboundContext { + get(key: string): string; +} + +export interface ApiResponseContext { + status: number; + body?: unknown; + headers?: Record; +} + +export interface KoaResponseContext { + status: number; + body?: unknown; + headers?: Record; + set(key: string, value: string): void; +} + +export type LowercaseKeys = { + [K in keyof T as Lowercase]: T[K]; +}; + +type NotErrorInstanceLike = { stack?: never; cause?: never }; + +/** + * Error message + */ +export interface Error extends NotErrorInstanceLike { + message?: string; + code?: string; +} +export interface Ping { + /** + * Current server time + */ + serverTime: string; // date-time +} +export interface PingGetContext { + request: PingGetRequestType; + response: PingGetResponseContext; +} +export interface PingGetRequestContext { + request: PingGetRequestType; +} +export interface PingGetRequestType extends InboundContext {} +export type PingGetResponseContext = PingGetResponseOK | PingGetResponseDefault; +export interface PingGetResponseDefault { + status: + | 100 + | 101 + | 102 + | 103 + | 201 + | 202 + | 203 + | 204 + | 205 + | 206 + | 207 + | 300 + | 301 + | 302 + | 303 + | 304 + | 305 + | 307 + | 308 + | 400 + | 401 + | 402 + | 403 + | 404 + | 405 + | 406 + | 407 + | 408 + | 409 + | 410 + | 411 + | 412 + | 413 + | 414 + | 415 + | 416 + | 417 + | 418 + | 419 + | 420 + | 421 + | 422 + | 423 + | 424 + | 426 + | 428 + | 429 + | 431 + | 451 + | 500 + | 501 + | 502 + | 503 + | 504 + | 505 + | 507 + | 511; + body: /* Error message */ Error; +} +export interface PingGetResponseOK { + status: 200; + body: Ping; +} +export interface PingHeadContext { + request: PingHeadRequestType; + response: PingHeadResponseContext; +} +export interface PingHeadRequestContext { + request: PingHeadRequestType; +} +export interface PingHeadRequestType extends InboundContext {} +export type PingHeadResponseContext = PingHeadResponseOK | PingHeadResponseDefault; +export interface PingHeadResponseDefault { + status: + | 100 + | 101 + | 102 + | 103 + | 201 + | 202 + | 203 + | 204 + | 205 + | 206 + | 207 + | 300 + | 301 + | 302 + | 303 + | 304 + | 305 + | 307 + | 308 + | 400 + | 401 + | 402 + | 403 + | 404 + | 405 + | 406 + | 407 + | 408 + | 409 + | 410 + | 411 + | 412 + | 413 + | 414 + | 415 + | 416 + | 417 + | 418 + | 419 + | 420 + | 421 + | 422 + | 423 + | 424 + | 426 + | 428 + | 429 + | 431 + | 451 + | 500 + | 501 + | 502 + | 503 + | 504 + | 505 + | 507 + | 511; + body: /* Error message */ Error; +} +export interface PingHeadResponseOK { + status: 200; +} + +export function setResponse( + response: Response, + responseContext: ResponseContext, +): void { + const koaResponse = response as unknown as KoaResponseContext; + koaResponse.status = responseContext.status; + if (responseContext.body) { + if (responseContext.body instanceof globalThis.Error) { + console.warn( + `Error instance or complex object with non-enumerable properties should not be used as response body directly because it'll cause firehose data inconsistency. Please use plain object literal instead.`, + ); + + const error = responseContext.body as unknown as Error; + const errorProperties: (keyof Error)[] = ['message', 'code']; + const convertedError = Object.fromEntries( + errorProperties + .map((key) => (error[key] === undefined ? undefined : [key, error[key]])) + .filter((entry) => entry !== undefined) as [string, unknown][], + ); + console.warn('The provided Error instance has been converted as a plain object:', convertedError); + koaResponse.body = convertedError; + } else { + koaResponse.body = responseContext.body; + } + } + if (responseContext.headers) { + for (const [key, value] of Object.entries(responseContext.headers)) { + koaResponse.set(key, value); + } + } +} + +/* eslint-enable */ +/* c8 ignore stop */ diff --git a/ts-init/services/ping/v2/swagger.yml b/ts-init/services/ping/v2/swagger.yml new file mode 100644 index 0000000..c9b6a5c --- /dev/null +++ b/ts-init/services/ping/v2/swagger.yml @@ -0,0 +1,81 @@ +openapi: 3.0.0 +info: + title: Ping Service + description: | + A service that accepts incoming pings. This is only used for testing. + + © Check Digit LLC. 2019-2024 + version: 2.0.6 + contact: + name: Check Digit +servers: + - url: /ping/v2 + +tags: + - name: Service Health + +x-firehose-logged: false + +paths: + /ping: + get: + description: Test service connectivity + tags: + - Service Health + operationId: ping-get + responses: + '200': + $ref: '#/components/responses/Ping' + default: + $ref: '#/components/responses/ServerError' + head: + description: Test service connectivity + tags: + - Service Health + operationId: ping-head + responses: + '200': + description: ping HEAD successful + content: + application/json: + schema: {} + default: + $ref: '#/components/responses/ServerError' + +components: + schemas: + Ping: + type: object + additionalProperties: false + required: + - serverTime + properties: + serverTime: + type: string + format: date-time + description: Current server time + + Error: + type: object + additionalProperties: false + description: Error message + properties: + message: + type: string + code: + type: string + + responses: + ServerError: + description: Server Error response + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + Ping: + description: ping successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Ping' diff --git a/ts-init/services/typing-fixture.d.ts b/ts-init/services/typing-fixture.d.ts new file mode 100644 index 0000000..d0ed53b --- /dev/null +++ b/ts-init/services/typing-fixture.d.ts @@ -0,0 +1,12 @@ +/* c8 ignore start */ +// services/typing-fixture.d.ts + +import type { TypedServices } from './index-fixture'; + +declare module '@checkdigit/fixture' { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface ResolvedServices extends TypedServices { + // + } +} +/* c8 ignore stop */ diff --git a/ts-init/services/typing.d.ts b/ts-init/services/typing.d.ts new file mode 100644 index 0000000..a493af1 --- /dev/null +++ b/ts-init/services/typing.d.ts @@ -0,0 +1,12 @@ +/* c8 ignore start */ +// services/typing.d.ts + +import type { Stringified } from '.'; + +declare global { + interface JSON { + stringify(input: T): Stringified; + } +} + +/* c8 ignore stop */ diff --git a/ts-init/src/api/v1/index.ts b/ts-init/src/api/v1/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/ts-init/src/api/v1/ping.spec.ts b/ts-init/src/api/v1/ping.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/ts-init/src/api/v1/swagger.yml b/ts-init/src/api/v1/swagger.yml new file mode 100644 index 0000000..07b199b --- /dev/null +++ b/ts-init/src/api/v1/swagger.yml @@ -0,0 +1,81 @@ +openapi: 3.0.0 +info: + title: Ping Service + description: | + A service that accepts incoming pings. This is only used for testing. + + © Check Digit LLC. 2019-2024 + version: 1.0.0 + contact: + name: Check Digit +servers: + - url: /ping/v1 + +tags: + - name: Service Health + +x-firehoseLogged: false + +paths: + /ping: + get: + description: Test service connectivity + tags: + - Service Health + operationId: ping-get + responses: + '200': + $ref: '#/components/responses/Ping' + default: + $ref: '#/components/responses/ServerError' + head: + description: Test service connectivity + tags: + - Service Health + operationId: ping-head + responses: + '200': + description: ping HEAD successful + content: + application/json: + schema: {} + default: + $ref: '#/components/responses/ServerError' + +components: + schemas: + Ping: + type: object + additionalProperties: false + required: + - serverTime + properties: + serverTime: + type: string + format: date-time + description: Current server time + + Error: + type: object + additionalProperties: false + description: Error message + properties: + message: + type: string + code: + type: string + + responses: + ServerError: + description: Server Error response + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + Ping: + description: ping successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Ping' diff --git a/ts-init/src/api/v1/test/util.spec.ts b/ts-init/src/api/v1/test/util.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/ts-init/src/api/v2/index.ts b/ts-init/src/api/v2/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/ts-init/src/api/v2/swagger.yml b/ts-init/src/api/v2/swagger.yml new file mode 100644 index 0000000..fb0741d --- /dev/null +++ b/ts-init/src/api/v2/swagger.yml @@ -0,0 +1,2 @@ +swagger: '2.0' +basePath: /ping/v2 diff --git a/ts-init/src/service/pgp.spec.ts b/ts-init/src/service/pgp.spec.ts new file mode 100644 index 0000000..e69de29