From 7925c60b7d7aee6f451679934fcf9c02289eb826 Mon Sep 17 00:00:00 2001 From: Johan Date: Mon, 19 May 2025 13:38:01 -0700 Subject: [PATCH 001/101] deno Initial commit to reset the codebase to start from just the readme --- .eslintrc.json | 40 - .gitignore | 3 - package-lock.json | 7517 ----------------------------- package.json | 30 - sandbox/configTemplate.json | 13 - sandbox/template.js | 60 - src/RequestBuilder/CoreBuilder.js | 62 - src/RequestBuilder/index.js | 37 - src/Requestor/index.js | 181 - src/Requestor/utils.js | 103 - src/index.js | 22 - src/resources/Groups.js | 19 - src/resources/People.js | 41 - src/resources/groups.test.js | 199 - 14 files changed, 8327 deletions(-) delete mode 100644 .eslintrc.json delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 sandbox/configTemplate.json delete mode 100644 sandbox/template.js delete mode 100644 src/RequestBuilder/CoreBuilder.js delete mode 100644 src/RequestBuilder/index.js delete mode 100644 src/Requestor/index.js delete mode 100644 src/Requestor/utils.js delete mode 100644 src/index.js delete mode 100644 src/resources/Groups.js delete mode 100644 src/resources/People.js delete mode 100644 src/resources/groups.test.js diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index bcb55d8..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "env": { - "browser": true, - "commonjs": true, - "es2021": true, - "jest": true - }, - "extends": "eslint:recommended", - "overrides": [ - ], - "parserOptions": { - "ecmaVersion": "latest" - }, - "globals": { - "Buffer": "readonly" - }, - "rules": { - "indent": ["error", 2, { "SwitchCase": 1 }], - "linebreak-style": ["error", "unix"], - "quotes": ["error", "single"], - "semi": ["error", "always"], - "no-trailing-spaces": "error", - "space-before-blocks": "error", - "keyword-spacing": "error", - "no-whitespace-before-property": "error", - "space-before-function-paren": ["error", "never"], - "space-in-parens": "error", - "space-infix-ops": "error", - "space-unary-ops": "error", - "spaced-comment": "error", - "no-tabs": "error", - "array-bracket-newline": ["error", "consistent"], - "block-spacing": "error", - "brace-style": ["error", "1tbs", { "allowSingleLine": true }], - "camelcase": "error", - "comma-dangle": "error", - "comma-spacing": "error", - "comma-style": "error" - } -} diff --git a/.gitignore b/.gitignore index 2ce5a56..e69de29 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +0,0 @@ -node_modules/ -sandbox/config.json -sandbox/index.js \ No newline at end of file diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 998ec98..0000000 --- a/package-lock.json +++ /dev/null @@ -1,7517 +0,0 @@ -{ - "name": "xmas", - "version": "1.0.0", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "xmas", - "version": "1.0.0", - "license": "ISC", - "devDependencies": { - "axios": "^1.2.1", - "eslint": "^8.29.0", - "jest": "^29.3.1" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", - "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.1.0", - "@jridgewell/trace-mapping": "^0.3.9" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", - "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==", - "dev": true, - "dependencies": { - "@babel/highlight": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.20.5.tgz", - "integrity": "sha512-KZXo2t10+/jxmkhNXc7pZTqRvSOIvVv/+lJwHS+B2rErwOyjuVRh60yVpb7liQ1U5t7lLJ1bz+t8tSypUZdm0g==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.20.5.tgz", - "integrity": "sha512-UdOWmk4pNWTm/4DlPUl/Pt4Gz4rcEMb7CY0Y3eJl5Yz1vI8ZJGmHWaVE55LoxRjdpx0z259GE9U5STA9atUinQ==", - "dev": true, - "dependencies": { - "@ampproject/remapping": "^2.1.0", - "@babel/code-frame": "^7.18.6", - "@babel/generator": "^7.20.5", - "@babel/helper-compilation-targets": "^7.20.0", - "@babel/helper-module-transforms": "^7.20.2", - "@babel/helpers": "^7.20.5", - "@babel/parser": "^7.20.5", - "@babel/template": "^7.18.10", - "@babel/traverse": "^7.20.5", - "@babel/types": "^7.20.5", - "convert-source-map": "^1.7.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.1", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true - }, - "node_modules/@babel/generator": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.20.5.tgz", - "integrity": "sha512-jl7JY2Ykn9S0yj4DQP82sYvPU+T3g0HFcWTqDLqiuA9tGRNIj9VfbtXGAYTTkyNEnQk1jkMGOdYka8aG/lulCA==", - "dev": true, - "dependencies": { - "@babel/types": "^7.20.5", - "@jridgewell/gen-mapping": "^0.3.2", - "jsesc": "^2.5.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/generator/node_modules/@jridgewell/gen-mapping": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", - "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", - "dev": true, - "dependencies": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.20.0", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.20.0.tgz", - "integrity": "sha512-0jp//vDGp9e8hZzBc6N/KwA5ZK3Wsm/pfm4CrY7vzegkVxc65SgSn6wYOnwHe9Js9HRQ1YTCKLGPzDtaS3RoLQ==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.20.0", - "@babel/helper-validator-option": "^7.18.6", - "browserslist": "^4.21.3", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz", - "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz", - "integrity": "sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==", - "dev": true, - "dependencies": { - "@babel/template": "^7.18.10", - "@babel/types": "^7.19.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz", - "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==", - "dev": true, - "dependencies": { - "@babel/types": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz", - "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==", - "dev": true, - "dependencies": { - "@babel/types": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.20.2", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.20.2.tgz", - "integrity": "sha512-zvBKyJXRbmK07XhMuujYoJ48B5yvvmM6+wcpv6Ivj4Yg6qO7NOZOSnvZN9CRl1zz1Z4cKf8YejmCMh8clOoOeA==", - "dev": true, - "dependencies": { - "@babel/helper-environment-visitor": "^7.18.9", - "@babel/helper-module-imports": "^7.18.6", - "@babel/helper-simple-access": "^7.20.2", - "@babel/helper-split-export-declaration": "^7.18.6", - "@babel/helper-validator-identifier": "^7.19.1", - "@babel/template": "^7.18.10", - "@babel/traverse": "^7.20.1", - "@babel/types": "^7.20.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.20.2", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.20.2.tgz", - "integrity": "sha512-8RvlJG2mj4huQ4pZ+rU9lqKi9ZKiRmuvGuM2HlWmkmgOhbs6zEAw6IEiJ5cQqGbDzGZOhwuOQNtZMi/ENLjZoQ==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-simple-access": { - "version": "7.20.2", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.20.2.tgz", - "integrity": "sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA==", - "dev": true, - "dependencies": { - "@babel/types": "^7.20.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz", - "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==", - "dev": true, - "dependencies": { - "@babel/types": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.19.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz", - "integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.19.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", - "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz", - "integrity": "sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.20.6.tgz", - "integrity": "sha512-Pf/OjgfgFRW5bApskEz5pvidpim7tEDPlFtKcNRXWmfHGn9IEI2W2flqRQXTFb7gIPTyK++N6rVHuwKut4XK6w==", - "dev": true, - "dependencies": { - "@babel/template": "^7.18.10", - "@babel/traverse": "^7.20.5", - "@babel/types": "^7.20.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", - "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.18.6", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/parser": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.5.tgz", - "integrity": "sha512-r27t/cy/m9uKLXQNWWebeCUHgnAZq0CpG1OwKRxzJMP1vpSU4bSIK2hq+/cp0bQxetkXx38n09rNu8jVkcK/zA==", - "dev": true, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz", - "integrity": "sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.20.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.20.0.tgz", - "integrity": "sha512-rd9TkG+u1CExzS4SM1BlMEhMXwFLKVjOAFFCDx9PbX5ycJWDoWMcwdJH9RhkPu1dOgn5TrxLot/Gx6lWFuAUNQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.19.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.18.10", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz", - "integrity": "sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.18.6", - "@babel/parser": "^7.18.10", - "@babel/types": "^7.18.10" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.20.5.tgz", - "integrity": "sha512-WM5ZNN3JITQIq9tFZaw1ojLU3WgWdtkxnhM1AegMS+PvHjkM5IXjmYEGY7yukz5XS4sJyEf2VzWjI8uAavhxBQ==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.18.6", - "@babel/generator": "^7.20.5", - "@babel/helper-environment-visitor": "^7.18.9", - "@babel/helper-function-name": "^7.19.0", - "@babel/helper-hoist-variables": "^7.18.6", - "@babel/helper-split-export-declaration": "^7.18.6", - "@babel/parser": "^7.20.5", - "@babel/types": "^7.20.5", - "debug": "^4.1.0", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.20.5.tgz", - "integrity": "sha512-c9fst/h2/dcF7H+MJKZ2T0KjEQ8hY/BNnDk/H3XY8C4Aw/eWQXWn/lWntHF9ooUBnGmEvbfGrTgLWc+um0YDUg==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.19.4", - "@babel/helper-validator-identifier": "^7.19.1", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true - }, - "node_modules/@eslint/eslintrc": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.3.tgz", - "integrity": "sha512-uj3pT6Mg+3t39fvLrj8iuCIJ38zKO9FpGtJ4BBJebJhEwjoT+KLVNCcHT5QC9NGRIEi7fZ0ZR8YRb884auB4Lg==", - "dev": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.4.0", - "globals": "^13.15.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.18.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.18.0.tgz", - "integrity": "sha512-/mR4KI8Ps2spmoc0Ulu9L7agOF0du1CZNQ3dke8yItYlyKNmGrkONemBbd6V8UTc1Wgcqn21t3WYB7dbRmh6/A==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@eslint/eslintrc/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.7", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.7.tgz", - "integrity": "sha512-kBbPWzN8oVMLb0hOUYXhmxggL/1cJE6ydvjDIGi9EnAGUyA7cLVKQg+d/Dsm+KZwx2czGHrCmMVLiyg8s5JPKw==", - "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^1.2.1", - "debug": "^4.1.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", - "dev": true - }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/console": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.3.1.tgz", - "integrity": "sha512-IRE6GD47KwcqA09RIWrabKdHPiKDGgtAL31xDxbi/RjQMsr+lY+ppxmHwY0dUEV3qvvxZzoe5Hl0RXZJOjQNUg==", - "dev": true, - "dependencies": { - "@jest/types": "^29.3.1", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.3.1", - "jest-util": "^29.3.1", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/core": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.3.1.tgz", - "integrity": "sha512-0ohVjjRex985w5MmO5L3u5GR1O30DexhBSpuwx2P+9ftyqHdJXnk7IUWiP80oHMvt7ubHCJHxV0a0vlKVuZirw==", - "dev": true, - "dependencies": { - "@jest/console": "^29.3.1", - "@jest/reporters": "^29.3.1", - "@jest/test-result": "^29.3.1", - "@jest/transform": "^29.3.1", - "@jest/types": "^29.3.1", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.2.0", - "jest-config": "^29.3.1", - "jest-haste-map": "^29.3.1", - "jest-message-util": "^29.3.1", - "jest-regex-util": "^29.2.0", - "jest-resolve": "^29.3.1", - "jest-resolve-dependencies": "^29.3.1", - "jest-runner": "^29.3.1", - "jest-runtime": "^29.3.1", - "jest-snapshot": "^29.3.1", - "jest-util": "^29.3.1", - "jest-validate": "^29.3.1", - "jest-watcher": "^29.3.1", - "micromatch": "^4.0.4", - "pretty-format": "^29.3.1", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/environment": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.3.1.tgz", - "integrity": "sha512-pMmvfOPmoa1c1QpfFW0nXYtNLpofqo4BrCIk6f2kW4JFeNlHV2t3vd+3iDLf31e2ot2Mec0uqZfmI+U0K2CFag==", - "dev": true, - "dependencies": { - "@jest/fake-timers": "^29.3.1", - "@jest/types": "^29.3.1", - "@types/node": "*", - "jest-mock": "^29.3.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.3.1.tgz", - "integrity": "sha512-QivM7GlSHSsIAWzgfyP8dgeExPRZ9BIe2LsdPyEhCGkZkoyA+kGsoIzbKAfZCvvRzfZioKwPtCZIt5SaoxYCvg==", - "dev": true, - "dependencies": { - "expect": "^29.3.1", - "jest-snapshot": "^29.3.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect-utils": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.3.1.tgz", - "integrity": "sha512-wlrznINZI5sMjwvUoLVk617ll/UYfGIZNxmbU+Pa7wmkL4vYzhV9R2pwVqUh4NWWuLQWkI8+8mOkxs//prKQ3g==", - "dev": true, - "dependencies": { - "jest-get-type": "^29.2.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/fake-timers": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.3.1.tgz", - "integrity": "sha512-iHTL/XpnDlFki9Tq0Q1GGuVeQ8BHZGIYsvCO5eN/O/oJaRzofG9Xndd9HuSDBI/0ZS79pg0iwn07OMTQ7ngF2A==", - "dev": true, - "dependencies": { - "@jest/types": "^29.3.1", - "@sinonjs/fake-timers": "^9.1.2", - "@types/node": "*", - "jest-message-util": "^29.3.1", - "jest-mock": "^29.3.1", - "jest-util": "^29.3.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/globals": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.3.1.tgz", - "integrity": "sha512-cTicd134vOcwO59OPaB6AmdHQMCtWOe+/DitpTZVxWgMJ+YvXL1HNAmPyiGbSHmF/mXVBkvlm8YYtQhyHPnV6Q==", - "dev": true, - "dependencies": { - "@jest/environment": "^29.3.1", - "@jest/expect": "^29.3.1", - "@jest/types": "^29.3.1", - "jest-mock": "^29.3.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/reporters": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.3.1.tgz", - "integrity": "sha512-GhBu3YFuDrcAYW/UESz1JphEAbvUjaY2vShRZRoRY1mxpCMB3yGSJ4j9n0GxVlEOdCf7qjvUfBCrTUUqhVfbRA==", - "dev": true, - "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.3.1", - "@jest/test-result": "^29.3.1", - "@jest/transform": "^29.3.1", - "@jest/types": "^29.3.1", - "@jridgewell/trace-mapping": "^0.3.15", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^5.1.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.3.1", - "jest-util": "^29.3.1", - "jest-worker": "^29.3.1", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/schemas": { - "version": "29.0.0", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.0.0.tgz", - "integrity": "sha512-3Ab5HgYIIAnS0HjqJHQYZS+zXc4tUmTmBH3z83ajI6afXp8X3ZtdLX+nXx+I7LNkJD7uN9LAVhgnjDgZa2z0kA==", - "dev": true, - "dependencies": { - "@sinclair/typebox": "^0.24.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/source-map": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.2.0.tgz", - "integrity": "sha512-1NX9/7zzI0nqa6+kgpSdKPK+WU1p+SJk3TloWZf5MzPbxri9UEeXX5bWZAPCzbQcyuAzubcdUHA7hcNznmRqWQ==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.15", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/test-result": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.3.1.tgz", - "integrity": "sha512-qeLa6qc0ddB0kuOZyZIhfN5q0e2htngokyTWsGriedsDhItisW7SDYZ7ceOe57Ii03sL988/03wAcBh3TChMGw==", - "dev": true, - "dependencies": { - "@jest/console": "^29.3.1", - "@jest/types": "^29.3.1", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/test-sequencer": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.3.1.tgz", - "integrity": "sha512-IqYvLbieTv20ArgKoAMyhLHNrVHJfzO6ARZAbQRlY4UGWfdDnLlZEF0BvKOMd77uIiIjSZRwq3Jb3Fa3I8+2UA==", - "dev": true, - "dependencies": { - "@jest/test-result": "^29.3.1", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.3.1", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/transform": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.3.1.tgz", - "integrity": "sha512-8wmCFBTVGYqFNLWfcOWoVuMuKYPUBTnTMDkdvFtAYELwDOl9RGwOsvQWGPFxDJ8AWY9xM/8xCXdqmPK3+Q5Lug==", - "dev": true, - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.3.1", - "@jridgewell/trace-mapping": "^0.3.15", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.3.1", - "jest-regex-util": "^29.2.0", - "jest-util": "^29.3.1", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.3.1.tgz", - "integrity": "sha512-d0S0jmmTpjnhCmNpApgX3jrUZgZ22ivKJRvL2lli5hpCRoNnp1f85r2/wpKfXuYu8E7Jjh1hGfhPyup1NM5AmA==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.0.0", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", - "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==", - "dev": true, - "dependencies": { - "@jridgewell/set-array": "^1.0.0", - "@jridgewell/sourcemap-codec": "^1.4.10" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", - "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "dev": true - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.17", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz", - "integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "3.1.0", - "@jridgewell/sourcemap-codec": "1.4.14" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@sinclair/typebox": { - "version": "0.24.51", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", - "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==", - "dev": true - }, - "node_modules/@sinonjs/commons": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", - "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", - "dev": true, - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz", - "integrity": "sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==", - "dev": true, - "dependencies": { - "@sinonjs/commons": "^1.7.0" - } - }, - "node_modules/@types/babel__core": { - "version": "7.1.20", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.20.tgz", - "integrity": "sha512-PVb6Bg2QuscZ30FvOU7z4guG6c926D9YRvOxEaelzndpMsvP+YM74Q/dAFASpg2l6+XLalxSGxcq/lrgYWZtyQ==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.4.tgz", - "integrity": "sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==", - "dev": true, - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.1.tgz", - "integrity": "sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.18.3.tgz", - "integrity": "sha512-1kbcJ40lLB7MHsj39U4Sh1uTd2E7rLEa79kmDpI6cy+XiXsteB3POdQomoq4FxszMrO3ZYchkhYJw7A2862b3w==", - "dev": true, - "dependencies": { - "@babel/types": "^7.3.0" - } - }, - "node_modules/@types/graceful-fs": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", - "integrity": "sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", - "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==", - "dev": true - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", - "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", - "dev": true, - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", - "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", - "dev": true, - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/node": { - "version": "18.11.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.11.tgz", - "integrity": "sha512-KJ021B1nlQUBLopzZmPBVuGU9un7WJd/W4ya7Ih02B4Uwky5Nja0yGYav2EfYIk0RR2Q9oVhf60S2XR1BCWJ2g==", - "dev": true - }, - "node_modules/@types/prettier": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz", - "integrity": "sha512-ri0UmynRRvZiiUJdiz38MmIblKK+oH30MztdBVR95dv/Ubw6neWSb8u1XpRb72L4qsZOhz+L+z9JD40SJmfWow==", - "dev": true - }, - "node_modules/@types/stack-utils": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", - "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", - "dev": true - }, - "node_modules/@types/yargs": { - "version": "17.0.16", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.16.tgz", - "integrity": "sha512-Mh3OP0oh8X7O7F9m5AplC+XHYLBWuPKNkGVD3gIZFLFebBnuFI2Nz5Sf8WLvwGxECJ8YjifQvFdh79ubODkdug==", - "dev": true, - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", - "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", - "dev": true - }, - "node_modules/acorn": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz", - "integrity": "sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true - }, - "node_modules/axios": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.2.1.tgz", - "integrity": "sha512-I88cFiGu9ryt/tfVEi4kX2SITsvDddTajXTOFmt2uK1ZVA8LytjtdeyefdQWEf5PU8w+4SSJDoYnggflB5tW4A==", - "dev": true, - "dependencies": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/babel-jest": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.3.1.tgz", - "integrity": "sha512-aard+xnMoxgjwV70t0L6wkW/3HQQtV+O0PEimxKgzNqCJnbYmroPojdP2tqKSOAt8QAKV/uSZU8851M7B5+fcA==", - "dev": true, - "dependencies": { - "@jest/transform": "^29.3.1", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.2.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.2.0.tgz", - "integrity": "sha512-TnspP2WNiR3GLfCsUNHqeXw0RoQ2f9U5hQ5L3XFpwuO8htQmSrhh8qsB6vi5Yi8+kuynN1yjDjQsPfkebmB6ZA==", - "dev": true, - "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", - "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", - "dev": true, - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.8.3", - "@babel/plugin-syntax-import-meta": "^7.8.3", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.8.3", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-top-level-await": "^7.8.3" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/babel-preset-jest": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.2.0.tgz", - "integrity": "sha512-z9JmMJppMxNv8N7fNRHvhMg9cvIkMxQBXgFkane3yKVEvEOP+kB50lk8DFRvF9PGqbyXxlmebKWhuDORO8RgdA==", - "dev": true, - "dependencies": { - "babel-plugin-jest-hoist": "^29.2.0", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "dependencies": { - "fill-range": "^7.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.21.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz", - "integrity": "sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - } - ], - "dependencies": { - "caniuse-lite": "^1.0.30001400", - "electron-to-chromium": "^1.4.251", - "node-releases": "^2.0.6", - "update-browserslist-db": "^1.0.9" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001436", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001436.tgz", - "integrity": "sha512-ZmWkKsnC2ifEPoWUvSAIGyOYwT+keAaaWPHiQ9DfMqS1t6tfuyFYoWR78TeZtznkEQ64+vGXH9cZrElwR2Mrxg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - } - ] - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/ci-info": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.7.0.tgz", - "integrity": "sha512-2CpRNYmImPx+RXKLq6jko/L07phmS9I02TyqkcNU20GCF/GgaWvc58hPtjxDX8lPpkdwc9sNh72V9k00S7ezog==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/cjs-module-lexer": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz", - "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==", - "dev": true - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true, - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } - }, - "node_modules/collect-v8-coverage": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", - "integrity": "sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==", - "dev": true - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/dedent": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", - "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", - "dev": true - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/deepmerge": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/diff-sequences": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.3.1.tgz", - "integrity": "sha512-hlM3QR272NXCi4pq+N4Kok4kOp6EsgOM3ZSpJI7Da3UAs+Ttsi8MRmB6trM/lhyzUxGfOgnpkHtgqm5Q/CTcfQ==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.4.284", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.284.tgz", - "integrity": "sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==", - "dev": true - }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/eslint": { - "version": "8.29.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.29.0.tgz", - "integrity": "sha512-isQ4EEiyUjZFbEKvEGJKKGBwXtvXX+zJbkVKCgTuB9t/+jUBcy8avhkEwWJecI15BkRkOYmvIM5ynbhRjEkoeg==", - "dev": true, - "dependencies": { - "@eslint/eslintrc": "^1.3.3", - "@humanwhocodes/config-array": "^0.11.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "ajv": "^6.10.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.1", - "eslint-utils": "^3.0.0", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.4.0", - "esquery": "^1.4.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.15.0", - "grapheme-splitter": "^1.0.4", - "ignore": "^5.2.0", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-sdsl": "^4.1.4", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "regexpp": "^3.2.0", - "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-scope": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", - "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/eslint-utils": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", - "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^2.0.0" - }, - "engines": { - "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=5" - } - }, - "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/eslint/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/eslint/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/globals": { - "version": "13.18.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.18.0.tgz", - "integrity": "sha512-/mR4KI8Ps2spmoc0Ulu9L7agOF0du1CZNQ3dke8yItYlyKNmGrkONemBbd6V8UTc1Wgcqn21t3WYB7dbRmh6/A==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/eslint/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/espree": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.4.1.tgz", - "integrity": "sha512-XwctdmTO6SIvCzd9810yyNzIrOrqNYV9Koizx4C/mRhf9uq0o4yHoCEU/670pOxOL/MSraektvSAji79kX90Vg==", - "dev": true, - "dependencies": { - "acorn": "^8.8.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", - "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", - "dev": true, - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/expect": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.3.1.tgz", - "integrity": "sha512-gGb1yTgU30Q0O/tQq+z30KBWv24ApkMgFUpvKBkyLUBL68Wv8dHdJxTBZFl/iT8K/bqDHvUYRH6IIN3rToopPA==", - "dev": true, - "dependencies": { - "@jest/expect-utils": "^29.3.1", - "jest-get-type": "^29.2.0", - "jest-matcher-utils": "^29.3.1", - "jest-message-util": "^29.3.1", - "jest-util": "^29.3.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "node_modules/fastq": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.14.0.tgz", - "integrity": "sha512-eR2D+V9/ExcbF9ls441yIuN6TI2ED1Y2ZcA5BmMtJsOkWOFRJQ0Jt0g1UwqXJJVAb+V+umH5Dfr8oh4EVP7VVg==", - "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "dev": true, - "dependencies": { - "bser": "2.1.1" - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", - "dev": true, - "dependencies": { - "flatted": "^3.1.0", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flatted": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", - "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", - "dev": true - }, - "node_modules/follow-redirects": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", - "dev": true - }, - "node_modules/grapheme-splitter": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", - "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", - "dev": true - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true - }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/ignore": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.1.tgz", - "integrity": "sha512-d2qQLzTJ9WxQftPAuEQpSPmKqzxePjzVbpAVv62AQ64NTL+wR4JkrVqR/LqFsFEUsHDAiId52mJteHDFuDkElA==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-fresh/node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/import-local": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", - "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", - "dev": true, - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true - }, - "node_modules/is-core-module": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", - "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", - "dev": true, - "dependencies": { - "has": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", - "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", - "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", - "dev": true, - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^3.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.5.tgz", - "integrity": "sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==", - "dev": true, - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.3.1.tgz", - "integrity": "sha512-6iWfL5DTT0Np6UYs/y5Niu7WIfNv/wRTtN5RSXt2DIEft3dx3zPuw/3WJQBCJfmEzvDiEKwoqMbGD9n49+qLSA==", - "dev": true, - "dependencies": { - "@jest/core": "^29.3.1", - "@jest/types": "^29.3.1", - "import-local": "^3.0.2", - "jest-cli": "^29.3.1" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-changed-files": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.2.0.tgz", - "integrity": "sha512-qPVmLLyBmvF5HJrY7krDisx6Voi8DmlV3GZYX0aFNbaQsZeoz1hfxcCMbqDGuQCxU1dJy9eYc2xscE8QrCCYaA==", - "dev": true, - "dependencies": { - "execa": "^5.0.0", - "p-limit": "^3.1.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.3.1.tgz", - "integrity": "sha512-wpr26sEvwb3qQQbdlmei+gzp6yoSSoSL6GsLPxnuayZSMrSd5Ka7IjAvatpIernBvT2+Ic6RLTg+jSebScmasg==", - "dev": true, - "dependencies": { - "@jest/environment": "^29.3.1", - "@jest/expect": "^29.3.1", - "@jest/test-result": "^29.3.1", - "@jest/types": "^29.3.1", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^0.7.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.3.1", - "jest-matcher-utils": "^29.3.1", - "jest-message-util": "^29.3.1", - "jest-runtime": "^29.3.1", - "jest-snapshot": "^29.3.1", - "jest-util": "^29.3.1", - "p-limit": "^3.1.0", - "pretty-format": "^29.3.1", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-cli": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.3.1.tgz", - "integrity": "sha512-TO/ewvwyvPOiBBuWZ0gm04z3WWP8TIK8acgPzE4IxgsLKQgb377NYGrQLc3Wl/7ndWzIH2CDNNsUjGxwLL43VQ==", - "dev": true, - "dependencies": { - "@jest/core": "^29.3.1", - "@jest/test-result": "^29.3.1", - "@jest/types": "^29.3.1", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "import-local": "^3.0.2", - "jest-config": "^29.3.1", - "jest-util": "^29.3.1", - "jest-validate": "^29.3.1", - "prompts": "^2.0.1", - "yargs": "^17.3.1" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-config": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.3.1.tgz", - "integrity": "sha512-y0tFHdj2WnTEhxmGUK1T7fgLen7YK4RtfvpLFBXfQkh2eMJAQq24Vx9472lvn5wg0MAO6B+iPfJfzdR9hJYalg==", - "dev": true, - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.3.1", - "@jest/types": "^29.3.1", - "babel-jest": "^29.3.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.3.1", - "jest-environment-node": "^29.3.1", - "jest-get-type": "^29.2.0", - "jest-regex-util": "^29.2.0", - "jest-resolve": "^29.3.1", - "jest-runner": "^29.3.1", - "jest-util": "^29.3.1", - "jest-validate": "^29.3.1", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^29.3.1", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/jest-diff": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.3.1.tgz", - "integrity": "sha512-vU8vyiO7568tmin2lA3r2DP8oRvzhvRcD4DjpXc6uGveQodyk7CKLhQlCSiwgx3g0pFaE88/KLZ0yaTWMc4Uiw==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.3.1", - "jest-get-type": "^29.2.0", - "pretty-format": "^29.3.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-docblock": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.2.0.tgz", - "integrity": "sha512-bkxUsxTgWQGbXV5IENmfiIuqZhJcyvF7tU4zJ/7ioTutdz4ToB5Yx6JOFBpgI+TphRY4lhOyCWGNH/QFQh5T6A==", - "dev": true, - "dependencies": { - "detect-newline": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-each": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.3.1.tgz", - "integrity": "sha512-qrZH7PmFB9rEzCSl00BWjZYuS1BSOH8lLuC0azQE9lQrAx3PWGKHTDudQiOSwIy5dGAJh7KA0ScYlCP7JxvFYA==", - "dev": true, - "dependencies": { - "@jest/types": "^29.3.1", - "chalk": "^4.0.0", - "jest-get-type": "^29.2.0", - "jest-util": "^29.3.1", - "pretty-format": "^29.3.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-environment-node": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.3.1.tgz", - "integrity": "sha512-xm2THL18Xf5sIHoU7OThBPtuH6Lerd+Y1NLYiZJlkE3hbE+7N7r8uvHIl/FkZ5ymKXJe/11SQuf3fv4v6rUMag==", - "dev": true, - "dependencies": { - "@jest/environment": "^29.3.1", - "@jest/fake-timers": "^29.3.1", - "@jest/types": "^29.3.1", - "@types/node": "*", - "jest-mock": "^29.3.1", - "jest-util": "^29.3.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-get-type": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.2.0.tgz", - "integrity": "sha512-uXNJlg8hKFEnDgFsrCjznB+sTxdkuqiCL6zMgA75qEbAJjJYTs9XPrvDctrEig2GDow22T/LvHgO57iJhXB/UA==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-haste-map": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.3.1.tgz", - "integrity": "sha512-/FFtvoG1xjbbPXQLFef+WSU4yrc0fc0Dds6aRPBojUid7qlPqZvxdUBA03HW0fnVHXVCnCdkuoghYItKNzc/0A==", - "dev": true, - "dependencies": { - "@jest/types": "^29.3.1", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.2.0", - "jest-util": "^29.3.1", - "jest-worker": "^29.3.1", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - } - }, - "node_modules/jest-leak-detector": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.3.1.tgz", - "integrity": "sha512-3DA/VVXj4zFOPagGkuqHnSQf1GZBmmlagpguxEERO6Pla2g84Q1MaVIB3YMxgUaFIaYag8ZnTyQgiZ35YEqAQA==", - "dev": true, - "dependencies": { - "jest-get-type": "^29.2.0", - "pretty-format": "^29.3.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-matcher-utils": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.3.1.tgz", - "integrity": "sha512-fkRMZUAScup3txIKfMe3AIZZmPEjWEdsPJFK3AIy5qRohWqQFg1qrmKfYXR9qEkNc7OdAu2N4KPHibEmy4HPeQ==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.3.1", - "jest-get-type": "^29.2.0", - "pretty-format": "^29.3.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-message-util": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.3.1.tgz", - "integrity": "sha512-lMJTbgNcDm5z+6KDxWtqOFWlGQxD6XaYwBqHR8kmpkP+WWWG90I35kdtQHY67Ay5CSuydkTBbJG+tH9JShFCyA==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.3.1", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.3.1", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-mock": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.3.1.tgz", - "integrity": "sha512-H8/qFDtDVMFvFP4X8NuOT3XRDzOUTz+FeACjufHzsOIBAxivLqkB1PoLCaJx9iPPQ8dZThHPp/G3WRWyMgA3JA==", - "dev": true, - "dependencies": { - "@jest/types": "^29.3.1", - "@types/node": "*", - "jest-util": "^29.3.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } - } - }, - "node_modules/jest-regex-util": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.2.0.tgz", - "integrity": "sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.3.1.tgz", - "integrity": "sha512-amXJgH/Ng712w3Uz5gqzFBBjxV8WFLSmNjoreBGMqxgCz5cH7swmBZzgBaCIOsvb0NbpJ0vgaSFdJqMdT+rADw==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.3.1", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.3.1", - "jest-validate": "^29.3.1", - "resolve": "^1.20.0", - "resolve.exports": "^1.1.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve-dependencies": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.3.1.tgz", - "integrity": "sha512-Vk0cYq0byRw2WluNmNWGqPeRnZ3p3hHmjJMp2dyyZeYIfiBskwq4rpiuGFR6QGAdbj58WC7HN4hQHjf2mpvrLA==", - "dev": true, - "dependencies": { - "jest-regex-util": "^29.2.0", - "jest-snapshot": "^29.3.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runner": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.3.1.tgz", - "integrity": "sha512-oFvcwRNrKMtE6u9+AQPMATxFcTySyKfLhvso7Sdk/rNpbhg4g2GAGCopiInk1OP4q6gz3n6MajW4+fnHWlU3bA==", - "dev": true, - "dependencies": { - "@jest/console": "^29.3.1", - "@jest/environment": "^29.3.1", - "@jest/test-result": "^29.3.1", - "@jest/transform": "^29.3.1", - "@jest/types": "^29.3.1", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.2.0", - "jest-environment-node": "^29.3.1", - "jest-haste-map": "^29.3.1", - "jest-leak-detector": "^29.3.1", - "jest-message-util": "^29.3.1", - "jest-resolve": "^29.3.1", - "jest-runtime": "^29.3.1", - "jest-util": "^29.3.1", - "jest-watcher": "^29.3.1", - "jest-worker": "^29.3.1", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runtime": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.3.1.tgz", - "integrity": "sha512-jLzkIxIqXwBEOZx7wx9OO9sxoZmgT2NhmQKzHQm1xwR1kNW/dn0OjxR424VwHHf1SPN6Qwlb5pp1oGCeFTQ62A==", - "dev": true, - "dependencies": { - "@jest/environment": "^29.3.1", - "@jest/fake-timers": "^29.3.1", - "@jest/globals": "^29.3.1", - "@jest/source-map": "^29.2.0", - "@jest/test-result": "^29.3.1", - "@jest/transform": "^29.3.1", - "@jest/types": "^29.3.1", - "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.3.1", - "jest-message-util": "^29.3.1", - "jest-mock": "^29.3.1", - "jest-regex-util": "^29.2.0", - "jest-resolve": "^29.3.1", - "jest-snapshot": "^29.3.1", - "jest-util": "^29.3.1", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.3.1.tgz", - "integrity": "sha512-+3JOc+s28upYLI2OJM4PWRGK9AgpsMs/ekNryUV0yMBClT9B1DF2u2qay8YxcQd338PPYSFNb0lsar1B49sLDA==", - "dev": true, - "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/traverse": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.3.1", - "@jest/transform": "^29.3.1", - "@jest/types": "^29.3.1", - "@types/babel__traverse": "^7.0.6", - "@types/prettier": "^2.1.5", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.3.1", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.3.1", - "jest-get-type": "^29.2.0", - "jest-haste-map": "^29.3.1", - "jest-matcher-utils": "^29.3.1", - "jest-message-util": "^29.3.1", - "jest-util": "^29.3.1", - "natural-compare": "^1.4.0", - "pretty-format": "^29.3.1", - "semver": "^7.3.5" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/jest-util": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.3.1.tgz", - "integrity": "sha512-7YOVZaiX7RJLv76ZfHt4nbNEzzTRiMW/IiOG7ZOKmTXmoGBxUDefgMAxQubu6WPVqP5zSzAdZG0FfLcC7HOIFQ==", - "dev": true, - "dependencies": { - "@jest/types": "^29.3.1", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.3.1.tgz", - "integrity": "sha512-N9Lr3oYR2Mpzuelp1F8negJR3YE+L1ebk1rYA5qYo9TTY3f9OWdptLoNSPP9itOCBIRBqjt/S5XHlzYglLN67g==", - "dev": true, - "dependencies": { - "@jest/types": "^29.3.1", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.2.0", - "leven": "^3.1.0", - "pretty-format": "^29.3.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-watcher": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.3.1.tgz", - "integrity": "sha512-RspXG2BQFDsZSRKGCT/NiNa8RkQ1iKAjrO0//soTMWx/QUt+OcxMqMSBxz23PYGqUuWm2+m2mNNsmj0eIoOaFg==", - "dev": true, - "dependencies": { - "@jest/test-result": "^29.3.1", - "@jest/types": "^29.3.1", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.3.1", - "string-length": "^4.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.3.1.tgz", - "integrity": "sha512-lY4AnnmsEWeiXirAIA0c9SDPbuCBq8IYuDVL8PMm0MZ2PEs2yPvRA/J64QBXuZp7CYKrDM/rmNrc9/i3KJQncw==", - "dev": true, - "dependencies": { - "@types/node": "*", - "jest-util": "^29.3.1", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/js-sdsl": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz", - "integrity": "sha512-dyBIzQBDkCqCu+0upx25Y2jGdbTGxE9fshMsCdK0ViOongpV+n5tXRcZY9v7CaVQ79AGS9KA1KHtojxiM7aXSQ==", - "dev": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/js-sdsl" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "dev": true, - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, - "node_modules/json5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", - "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", - "dev": true, - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true - }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "dependencies": { - "tmpl": "1.0.5" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, - "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, - "dependencies": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true - }, - "node_modules/node-releases": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz", - "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==", - "dev": true - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", - "dev": true, - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.3" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-locate/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pirates": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz", - "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/pretty-format": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.3.1.tgz", - "integrity": "sha512-FyLnmb1cYJV8biEIiRyzRFvs2lry7PPIvOqKVe1GCUEYg4YGmlx1qG9EJNMxArYm7piII4qb8UV1Pncq5dxmcg==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.0.0", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true - }, - "node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true - }, - "node_modules/regexpp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", - "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", - "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", - "dev": true, - "dependencies": { - "is-core-module": "^2.9.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve.exports": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.0.tgz", - "integrity": "sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "dev": true, - "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true - }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true - }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", - "integrity": "sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - } - ], - "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" - }, - "bin": { - "browserslist-lint": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/v8-to-istanbul": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz", - "integrity": "sha512-74Y4LqY74kLE6IFyIjPtkSTWzUZmj8tdHT9Ii/26dvQ6K9Dl2NbEfj0XgU2sHCtKgt5VupqhlO/5aWuqS+IY1w==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^1.6.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/v8-to-istanbul/node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true - }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "dependencies": { - "makeerror": "1.0.12" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/yargs": { - "version": "17.6.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.6.2.tgz", - "integrity": "sha512-1/9UrdHjDZc0eOU0HxOHoS78C69UD3JRMvzlJ7S79S2nTaWRA/whGCTV8o9e/N/1Va9YIV7Q4sOxD8VV4pCWOw==", - "dev": true, - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - }, - "dependencies": { - "@ampproject/remapping": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", - "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==", - "dev": true, - "requires": { - "@jridgewell/gen-mapping": "^0.1.0", - "@jridgewell/trace-mapping": "^0.3.9" - } - }, - "@babel/code-frame": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", - "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==", - "dev": true, - "requires": { - "@babel/highlight": "^7.18.6" - } - }, - "@babel/compat-data": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.20.5.tgz", - "integrity": "sha512-KZXo2t10+/jxmkhNXc7pZTqRvSOIvVv/+lJwHS+B2rErwOyjuVRh60yVpb7liQ1U5t7lLJ1bz+t8tSypUZdm0g==", - "dev": true - }, - "@babel/core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.20.5.tgz", - "integrity": "sha512-UdOWmk4pNWTm/4DlPUl/Pt4Gz4rcEMb7CY0Y3eJl5Yz1vI8ZJGmHWaVE55LoxRjdpx0z259GE9U5STA9atUinQ==", - "dev": true, - "requires": { - "@ampproject/remapping": "^2.1.0", - "@babel/code-frame": "^7.18.6", - "@babel/generator": "^7.20.5", - "@babel/helper-compilation-targets": "^7.20.0", - "@babel/helper-module-transforms": "^7.20.2", - "@babel/helpers": "^7.20.5", - "@babel/parser": "^7.20.5", - "@babel/template": "^7.18.10", - "@babel/traverse": "^7.20.5", - "@babel/types": "^7.20.5", - "convert-source-map": "^1.7.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.1", - "semver": "^6.3.0" - }, - "dependencies": { - "convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true - } - } - }, - "@babel/generator": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.20.5.tgz", - "integrity": "sha512-jl7JY2Ykn9S0yj4DQP82sYvPU+T3g0HFcWTqDLqiuA9tGRNIj9VfbtXGAYTTkyNEnQk1jkMGOdYka8aG/lulCA==", - "dev": true, - "requires": { - "@babel/types": "^7.20.5", - "@jridgewell/gen-mapping": "^0.3.2", - "jsesc": "^2.5.1" - }, - "dependencies": { - "@jridgewell/gen-mapping": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", - "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", - "dev": true, - "requires": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" - } - } - } - }, - "@babel/helper-compilation-targets": { - "version": "7.20.0", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.20.0.tgz", - "integrity": "sha512-0jp//vDGp9e8hZzBc6N/KwA5ZK3Wsm/pfm4CrY7vzegkVxc65SgSn6wYOnwHe9Js9HRQ1YTCKLGPzDtaS3RoLQ==", - "dev": true, - "requires": { - "@babel/compat-data": "^7.20.0", - "@babel/helper-validator-option": "^7.18.6", - "browserslist": "^4.21.3", - "semver": "^6.3.0" - } - }, - "@babel/helper-environment-visitor": { - "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz", - "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==", - "dev": true - }, - "@babel/helper-function-name": { - "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz", - "integrity": "sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==", - "dev": true, - "requires": { - "@babel/template": "^7.18.10", - "@babel/types": "^7.19.0" - } - }, - "@babel/helper-hoist-variables": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz", - "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==", - "dev": true, - "requires": { - "@babel/types": "^7.18.6" - } - }, - "@babel/helper-module-imports": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz", - "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==", - "dev": true, - "requires": { - "@babel/types": "^7.18.6" - } - }, - "@babel/helper-module-transforms": { - "version": "7.20.2", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.20.2.tgz", - "integrity": "sha512-zvBKyJXRbmK07XhMuujYoJ48B5yvvmM6+wcpv6Ivj4Yg6qO7NOZOSnvZN9CRl1zz1Z4cKf8YejmCMh8clOoOeA==", - "dev": true, - "requires": { - "@babel/helper-environment-visitor": "^7.18.9", - "@babel/helper-module-imports": "^7.18.6", - "@babel/helper-simple-access": "^7.20.2", - "@babel/helper-split-export-declaration": "^7.18.6", - "@babel/helper-validator-identifier": "^7.19.1", - "@babel/template": "^7.18.10", - "@babel/traverse": "^7.20.1", - "@babel/types": "^7.20.2" - } - }, - "@babel/helper-plugin-utils": { - "version": "7.20.2", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.20.2.tgz", - "integrity": "sha512-8RvlJG2mj4huQ4pZ+rU9lqKi9ZKiRmuvGuM2HlWmkmgOhbs6zEAw6IEiJ5cQqGbDzGZOhwuOQNtZMi/ENLjZoQ==", - "dev": true - }, - "@babel/helper-simple-access": { - "version": "7.20.2", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.20.2.tgz", - "integrity": "sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA==", - "dev": true, - "requires": { - "@babel/types": "^7.20.2" - } - }, - "@babel/helper-split-export-declaration": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz", - "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==", - "dev": true, - "requires": { - "@babel/types": "^7.18.6" - } - }, - "@babel/helper-string-parser": { - "version": "7.19.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz", - "integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==", - "dev": true - }, - "@babel/helper-validator-identifier": { - "version": "7.19.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", - "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==", - "dev": true - }, - "@babel/helper-validator-option": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz", - "integrity": "sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==", - "dev": true - }, - "@babel/helpers": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.20.6.tgz", - "integrity": "sha512-Pf/OjgfgFRW5bApskEz5pvidpim7tEDPlFtKcNRXWmfHGn9IEI2W2flqRQXTFb7gIPTyK++N6rVHuwKut4XK6w==", - "dev": true, - "requires": { - "@babel/template": "^7.18.10", - "@babel/traverse": "^7.20.5", - "@babel/types": "^7.20.5" - } - }, - "@babel/highlight": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", - "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", - "dev": true, - "requires": { - "@babel/helper-validator-identifier": "^7.18.6", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, - "@babel/parser": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.5.tgz", - "integrity": "sha512-r27t/cy/m9uKLXQNWWebeCUHgnAZq0CpG1OwKRxzJMP1vpSU4bSIK2hq+/cp0bQxetkXx38n09rNu8jVkcK/zA==", - "dev": true - }, - "@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.12.13" - } - }, - "@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.10.4" - } - }, - "@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-jsx": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz", - "integrity": "sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.18.6" - } - }, - "@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.10.4" - } - }, - "@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.10.4" - } - }, - "@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.14.5" - } - }, - "@babel/plugin-syntax-typescript": { - "version": "7.20.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.20.0.tgz", - "integrity": "sha512-rd9TkG+u1CExzS4SM1BlMEhMXwFLKVjOAFFCDx9PbX5ycJWDoWMcwdJH9RhkPu1dOgn5TrxLot/Gx6lWFuAUNQ==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.19.0" - } - }, - "@babel/template": { - "version": "7.18.10", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz", - "integrity": "sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.18.6", - "@babel/parser": "^7.18.10", - "@babel/types": "^7.18.10" - } - }, - "@babel/traverse": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.20.5.tgz", - "integrity": "sha512-WM5ZNN3JITQIq9tFZaw1ojLU3WgWdtkxnhM1AegMS+PvHjkM5IXjmYEGY7yukz5XS4sJyEf2VzWjI8uAavhxBQ==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.18.6", - "@babel/generator": "^7.20.5", - "@babel/helper-environment-visitor": "^7.18.9", - "@babel/helper-function-name": "^7.19.0", - "@babel/helper-hoist-variables": "^7.18.6", - "@babel/helper-split-export-declaration": "^7.18.6", - "@babel/parser": "^7.20.5", - "@babel/types": "^7.20.5", - "debug": "^4.1.0", - "globals": "^11.1.0" - } - }, - "@babel/types": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.20.5.tgz", - "integrity": "sha512-c9fst/h2/dcF7H+MJKZ2T0KjEQ8hY/BNnDk/H3XY8C4Aw/eWQXWn/lWntHF9ooUBnGmEvbfGrTgLWc+um0YDUg==", - "dev": true, - "requires": { - "@babel/helper-string-parser": "^7.19.4", - "@babel/helper-validator-identifier": "^7.19.1", - "to-fast-properties": "^2.0.0" - } - }, - "@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true - }, - "@eslint/eslintrc": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.3.tgz", - "integrity": "sha512-uj3pT6Mg+3t39fvLrj8iuCIJ38zKO9FpGtJ4BBJebJhEwjoT+KLVNCcHT5QC9NGRIEi7fZ0ZR8YRb884auB4Lg==", - "dev": true, - "requires": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.4.0", - "globals": "^13.15.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "dependencies": { - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "globals": { - "version": "13.18.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.18.0.tgz", - "integrity": "sha512-/mR4KI8Ps2spmoc0Ulu9L7agOF0du1CZNQ3dke8yItYlyKNmGrkONemBbd6V8UTc1Wgcqn21t3WYB7dbRmh6/A==", - "dev": true, - "requires": { - "type-fest": "^0.20.2" - } - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, - "type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true - } - } - }, - "@humanwhocodes/config-array": { - "version": "0.11.7", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.7.tgz", - "integrity": "sha512-kBbPWzN8oVMLb0hOUYXhmxggL/1cJE6ydvjDIGi9EnAGUyA7cLVKQg+d/Dsm+KZwx2czGHrCmMVLiyg8s5JPKw==", - "dev": true, - "requires": { - "@humanwhocodes/object-schema": "^1.2.1", - "debug": "^4.1.1", - "minimatch": "^3.0.5" - } - }, - "@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true - }, - "@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", - "dev": true - }, - "@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "requires": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - } - }, - "@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true - }, - "@jest/console": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.3.1.tgz", - "integrity": "sha512-IRE6GD47KwcqA09RIWrabKdHPiKDGgtAL31xDxbi/RjQMsr+lY+ppxmHwY0dUEV3qvvxZzoe5Hl0RXZJOjQNUg==", - "dev": true, - "requires": { - "@jest/types": "^29.3.1", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.3.1", - "jest-util": "^29.3.1", - "slash": "^3.0.0" - } - }, - "@jest/core": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.3.1.tgz", - "integrity": "sha512-0ohVjjRex985w5MmO5L3u5GR1O30DexhBSpuwx2P+9ftyqHdJXnk7IUWiP80oHMvt7ubHCJHxV0a0vlKVuZirw==", - "dev": true, - "requires": { - "@jest/console": "^29.3.1", - "@jest/reporters": "^29.3.1", - "@jest/test-result": "^29.3.1", - "@jest/transform": "^29.3.1", - "@jest/types": "^29.3.1", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.2.0", - "jest-config": "^29.3.1", - "jest-haste-map": "^29.3.1", - "jest-message-util": "^29.3.1", - "jest-regex-util": "^29.2.0", - "jest-resolve": "^29.3.1", - "jest-resolve-dependencies": "^29.3.1", - "jest-runner": "^29.3.1", - "jest-runtime": "^29.3.1", - "jest-snapshot": "^29.3.1", - "jest-util": "^29.3.1", - "jest-validate": "^29.3.1", - "jest-watcher": "^29.3.1", - "micromatch": "^4.0.4", - "pretty-format": "^29.3.1", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" - } - }, - "@jest/environment": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.3.1.tgz", - "integrity": "sha512-pMmvfOPmoa1c1QpfFW0nXYtNLpofqo4BrCIk6f2kW4JFeNlHV2t3vd+3iDLf31e2ot2Mec0uqZfmI+U0K2CFag==", - "dev": true, - "requires": { - "@jest/fake-timers": "^29.3.1", - "@jest/types": "^29.3.1", - "@types/node": "*", - "jest-mock": "^29.3.1" - } - }, - "@jest/expect": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.3.1.tgz", - "integrity": "sha512-QivM7GlSHSsIAWzgfyP8dgeExPRZ9BIe2LsdPyEhCGkZkoyA+kGsoIzbKAfZCvvRzfZioKwPtCZIt5SaoxYCvg==", - "dev": true, - "requires": { - "expect": "^29.3.1", - "jest-snapshot": "^29.3.1" - } - }, - "@jest/expect-utils": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.3.1.tgz", - "integrity": "sha512-wlrznINZI5sMjwvUoLVk617ll/UYfGIZNxmbU+Pa7wmkL4vYzhV9R2pwVqUh4NWWuLQWkI8+8mOkxs//prKQ3g==", - "dev": true, - "requires": { - "jest-get-type": "^29.2.0" - } - }, - "@jest/fake-timers": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.3.1.tgz", - "integrity": "sha512-iHTL/XpnDlFki9Tq0Q1GGuVeQ8BHZGIYsvCO5eN/O/oJaRzofG9Xndd9HuSDBI/0ZS79pg0iwn07OMTQ7ngF2A==", - "dev": true, - "requires": { - "@jest/types": "^29.3.1", - "@sinonjs/fake-timers": "^9.1.2", - "@types/node": "*", - "jest-message-util": "^29.3.1", - "jest-mock": "^29.3.1", - "jest-util": "^29.3.1" - } - }, - "@jest/globals": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.3.1.tgz", - "integrity": "sha512-cTicd134vOcwO59OPaB6AmdHQMCtWOe+/DitpTZVxWgMJ+YvXL1HNAmPyiGbSHmF/mXVBkvlm8YYtQhyHPnV6Q==", - "dev": true, - "requires": { - "@jest/environment": "^29.3.1", - "@jest/expect": "^29.3.1", - "@jest/types": "^29.3.1", - "jest-mock": "^29.3.1" - } - }, - "@jest/reporters": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.3.1.tgz", - "integrity": "sha512-GhBu3YFuDrcAYW/UESz1JphEAbvUjaY2vShRZRoRY1mxpCMB3yGSJ4j9n0GxVlEOdCf7qjvUfBCrTUUqhVfbRA==", - "dev": true, - "requires": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.3.1", - "@jest/test-result": "^29.3.1", - "@jest/transform": "^29.3.1", - "@jest/types": "^29.3.1", - "@jridgewell/trace-mapping": "^0.3.15", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^5.1.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.3.1", - "jest-util": "^29.3.1", - "jest-worker": "^29.3.1", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" - } - }, - "@jest/schemas": { - "version": "29.0.0", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.0.0.tgz", - "integrity": "sha512-3Ab5HgYIIAnS0HjqJHQYZS+zXc4tUmTmBH3z83ajI6afXp8X3ZtdLX+nXx+I7LNkJD7uN9LAVhgnjDgZa2z0kA==", - "dev": true, - "requires": { - "@sinclair/typebox": "^0.24.1" - } - }, - "@jest/source-map": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.2.0.tgz", - "integrity": "sha512-1NX9/7zzI0nqa6+kgpSdKPK+WU1p+SJk3TloWZf5MzPbxri9UEeXX5bWZAPCzbQcyuAzubcdUHA7hcNznmRqWQ==", - "dev": true, - "requires": { - "@jridgewell/trace-mapping": "^0.3.15", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" - } - }, - "@jest/test-result": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.3.1.tgz", - "integrity": "sha512-qeLa6qc0ddB0kuOZyZIhfN5q0e2htngokyTWsGriedsDhItisW7SDYZ7ceOe57Ii03sL988/03wAcBh3TChMGw==", - "dev": true, - "requires": { - "@jest/console": "^29.3.1", - "@jest/types": "^29.3.1", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - } - }, - "@jest/test-sequencer": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.3.1.tgz", - "integrity": "sha512-IqYvLbieTv20ArgKoAMyhLHNrVHJfzO6ARZAbQRlY4UGWfdDnLlZEF0BvKOMd77uIiIjSZRwq3Jb3Fa3I8+2UA==", - "dev": true, - "requires": { - "@jest/test-result": "^29.3.1", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.3.1", - "slash": "^3.0.0" - } - }, - "@jest/transform": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.3.1.tgz", - "integrity": "sha512-8wmCFBTVGYqFNLWfcOWoVuMuKYPUBTnTMDkdvFtAYELwDOl9RGwOsvQWGPFxDJ8AWY9xM/8xCXdqmPK3+Q5Lug==", - "dev": true, - "requires": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.3.1", - "@jridgewell/trace-mapping": "^0.3.15", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.3.1", - "jest-regex-util": "^29.2.0", - "jest-util": "^29.3.1", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.1" - } - }, - "@jest/types": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.3.1.tgz", - "integrity": "sha512-d0S0jmmTpjnhCmNpApgX3jrUZgZ22ivKJRvL2lli5hpCRoNnp1f85r2/wpKfXuYu8E7Jjh1hGfhPyup1NM5AmA==", - "dev": true, - "requires": { - "@jest/schemas": "^29.0.0", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - } - }, - "@jridgewell/gen-mapping": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", - "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==", - "dev": true, - "requires": { - "@jridgewell/set-array": "^1.0.0", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "@jridgewell/resolve-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", - "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", - "dev": true - }, - "@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", - "dev": true - }, - "@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "dev": true - }, - "@jridgewell/trace-mapping": { - "version": "0.3.17", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz", - "integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==", - "dev": true, - "requires": { - "@jridgewell/resolve-uri": "3.1.0", - "@jridgewell/sourcemap-codec": "1.4.14" - } - }, - "@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "requires": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - } - }, - "@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true - }, - "@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "requires": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - } - }, - "@sinclair/typebox": { - "version": "0.24.51", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", - "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==", - "dev": true - }, - "@sinonjs/commons": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", - "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", - "dev": true, - "requires": { - "type-detect": "4.0.8" - } - }, - "@sinonjs/fake-timers": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz", - "integrity": "sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==", - "dev": true, - "requires": { - "@sinonjs/commons": "^1.7.0" - } - }, - "@types/babel__core": { - "version": "7.1.20", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.20.tgz", - "integrity": "sha512-PVb6Bg2QuscZ30FvOU7z4guG6c926D9YRvOxEaelzndpMsvP+YM74Q/dAFASpg2l6+XLalxSGxcq/lrgYWZtyQ==", - "dev": true, - "requires": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "@types/babel__generator": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.4.tgz", - "integrity": "sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==", - "dev": true, - "requires": { - "@babel/types": "^7.0.0" - } - }, - "@types/babel__template": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.1.tgz", - "integrity": "sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==", - "dev": true, - "requires": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "@types/babel__traverse": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.18.3.tgz", - "integrity": "sha512-1kbcJ40lLB7MHsj39U4Sh1uTd2E7rLEa79kmDpI6cy+XiXsteB3POdQomoq4FxszMrO3ZYchkhYJw7A2862b3w==", - "dev": true, - "requires": { - "@babel/types": "^7.3.0" - } - }, - "@types/graceful-fs": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", - "integrity": "sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@types/istanbul-lib-coverage": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", - "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==", - "dev": true - }, - "@types/istanbul-lib-report": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", - "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", - "dev": true, - "requires": { - "@types/istanbul-lib-coverage": "*" - } - }, - "@types/istanbul-reports": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", - "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", - "dev": true, - "requires": { - "@types/istanbul-lib-report": "*" - } - }, - "@types/node": { - "version": "18.11.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.11.tgz", - "integrity": "sha512-KJ021B1nlQUBLopzZmPBVuGU9un7WJd/W4ya7Ih02B4Uwky5Nja0yGYav2EfYIk0RR2Q9oVhf60S2XR1BCWJ2g==", - "dev": true - }, - "@types/prettier": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz", - "integrity": "sha512-ri0UmynRRvZiiUJdiz38MmIblKK+oH30MztdBVR95dv/Ubw6neWSb8u1XpRb72L4qsZOhz+L+z9JD40SJmfWow==", - "dev": true - }, - "@types/stack-utils": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", - "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", - "dev": true - }, - "@types/yargs": { - "version": "17.0.16", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.16.tgz", - "integrity": "sha512-Mh3OP0oh8X7O7F9m5AplC+XHYLBWuPKNkGVD3gIZFLFebBnuFI2Nz5Sf8WLvwGxECJ8YjifQvFdh79ubODkdug==", - "dev": true, - "requires": { - "@types/yargs-parser": "*" - } - }, - "@types/yargs-parser": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", - "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", - "dev": true - }, - "acorn": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz", - "integrity": "sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==", - "dev": true - }, - "acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "requires": {} - }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "requires": { - "type-fest": "^0.21.3" - } - }, - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "requires": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - } - }, - "argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "requires": { - "sprintf-js": "~1.0.2" - } - }, - "asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true - }, - "axios": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.2.1.tgz", - "integrity": "sha512-I88cFiGu9ryt/tfVEi4kX2SITsvDddTajXTOFmt2uK1ZVA8LytjtdeyefdQWEf5PU8w+4SSJDoYnggflB5tW4A==", - "dev": true, - "requires": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "babel-jest": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.3.1.tgz", - "integrity": "sha512-aard+xnMoxgjwV70t0L6wkW/3HQQtV+O0PEimxKgzNqCJnbYmroPojdP2tqKSOAt8QAKV/uSZU8851M7B5+fcA==", - "dev": true, - "requires": { - "@jest/transform": "^29.3.1", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.2.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - } - }, - "babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - } - }, - "babel-plugin-jest-hoist": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.2.0.tgz", - "integrity": "sha512-TnspP2WNiR3GLfCsUNHqeXw0RoQ2f9U5hQ5L3XFpwuO8htQmSrhh8qsB6vi5Yi8+kuynN1yjDjQsPfkebmB6ZA==", - "dev": true, - "requires": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - } - }, - "babel-preset-current-node-syntax": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", - "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", - "dev": true, - "requires": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.8.3", - "@babel/plugin-syntax-import-meta": "^7.8.3", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.8.3", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-top-level-await": "^7.8.3" - } - }, - "babel-preset-jest": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.2.0.tgz", - "integrity": "sha512-z9JmMJppMxNv8N7fNRHvhMg9cvIkMxQBXgFkane3yKVEvEOP+kB50lk8DFRvF9PGqbyXxlmebKWhuDORO8RgdA==", - "dev": true, - "requires": { - "babel-plugin-jest-hoist": "^29.2.0", - "babel-preset-current-node-syntax": "^1.0.0" - } - }, - "balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "requires": { - "fill-range": "^7.0.1" - } - }, - "browserslist": { - "version": "4.21.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz", - "integrity": "sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==", - "dev": true, - "requires": { - "caniuse-lite": "^1.0.30001400", - "electron-to-chromium": "^1.4.251", - "node-releases": "^2.0.6", - "update-browserslist-db": "^1.0.9" - } - }, - "bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "requires": { - "node-int64": "^0.4.0" - } - }, - "buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true - }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true - }, - "camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true - }, - "caniuse-lite": { - "version": "1.0.30001436", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001436.tgz", - "integrity": "sha512-ZmWkKsnC2ifEPoWUvSAIGyOYwT+keAaaWPHiQ9DfMqS1t6tfuyFYoWR78TeZtznkEQ64+vGXH9cZrElwR2Mrxg==", - "dev": true - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true - }, - "ci-info": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.7.0.tgz", - "integrity": "sha512-2CpRNYmImPx+RXKLq6jko/L07phmS9I02TyqkcNU20GCF/GgaWvc58hPtjxDX8lPpkdwc9sNh72V9k00S7ezog==", - "dev": true - }, - "cjs-module-lexer": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz", - "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==", - "dev": true - }, - "cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - } - }, - "co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true - }, - "collect-v8-coverage": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", - "integrity": "sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==", - "dev": true - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "requires": { - "delayed-stream": "~1.0.0" - } - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, - "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "requires": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - } - }, - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - }, - "dedent": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", - "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", - "dev": true - }, - "deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "deepmerge": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "dev": true - }, - "delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true - }, - "detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true - }, - "diff-sequences": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.3.1.tgz", - "integrity": "sha512-hlM3QR272NXCi4pq+N4Kok4kOp6EsgOM3ZSpJI7Da3UAs+Ttsi8MRmB6trM/lhyzUxGfOgnpkHtgqm5Q/CTcfQ==", - "dev": true - }, - "doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "requires": { - "esutils": "^2.0.2" - } - }, - "electron-to-chromium": { - "version": "1.4.284", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.284.tgz", - "integrity": "sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==", - "dev": true - }, - "emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "requires": { - "is-arrayish": "^0.2.1" - } - }, - "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true - }, - "escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true - }, - "eslint": { - "version": "8.29.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.29.0.tgz", - "integrity": "sha512-isQ4EEiyUjZFbEKvEGJKKGBwXtvXX+zJbkVKCgTuB9t/+jUBcy8avhkEwWJecI15BkRkOYmvIM5ynbhRjEkoeg==", - "dev": true, - "requires": { - "@eslint/eslintrc": "^1.3.3", - "@humanwhocodes/config-array": "^0.11.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "ajv": "^6.10.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.1", - "eslint-utils": "^3.0.0", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.4.0", - "esquery": "^1.4.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.15.0", - "grapheme-splitter": "^1.0.4", - "ignore": "^5.2.0", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-sdsl": "^4.1.4", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "regexpp": "^3.2.0", - "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", - "text-table": "^0.2.0" - }, - "dependencies": { - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true - }, - "find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "requires": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - } - }, - "globals": { - "version": "13.18.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.18.0.tgz", - "integrity": "sha512-/mR4KI8Ps2spmoc0Ulu9L7agOF0du1CZNQ3dke8yItYlyKNmGrkONemBbd6V8UTc1Wgcqn21t3WYB7dbRmh6/A==", - "dev": true, - "requires": { - "type-fest": "^0.20.2" - } - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, - "locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "requires": { - "p-locate": "^5.0.0" - } - }, - "p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "requires": { - "p-limit": "^3.0.2" - } - }, - "type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true - } - } - }, - "eslint-scope": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", - "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", - "dev": true, - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - } - }, - "eslint-utils": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", - "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^2.0.0" - }, - "dependencies": { - "eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "dev": true - } - } - }, - "eslint-visitor-keys": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", - "dev": true - }, - "espree": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.4.1.tgz", - "integrity": "sha512-XwctdmTO6SIvCzd9810yyNzIrOrqNYV9Koizx4C/mRhf9uq0o4yHoCEU/670pOxOL/MSraektvSAji79kX90Vg==", - "dev": true, - "requires": { - "acorn": "^8.8.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.3.0" - } - }, - "esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true - }, - "esquery": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", - "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", - "dev": true, - "requires": { - "estraverse": "^5.1.0" - } - }, - "esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "requires": { - "estraverse": "^5.2.0" - } - }, - "estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true - }, - "esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true - }, - "execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "requires": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - } - }, - "exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", - "dev": true - }, - "expect": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.3.1.tgz", - "integrity": "sha512-gGb1yTgU30Q0O/tQq+z30KBWv24ApkMgFUpvKBkyLUBL68Wv8dHdJxTBZFl/iT8K/bqDHvUYRH6IIN3rToopPA==", - "dev": true, - "requires": { - "@jest/expect-utils": "^29.3.1", - "jest-get-type": "^29.2.0", - "jest-matcher-utils": "^29.3.1", - "jest-message-util": "^29.3.1", - "jest-util": "^29.3.1" - } - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "fastq": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.14.0.tgz", - "integrity": "sha512-eR2D+V9/ExcbF9ls441yIuN6TI2ED1Y2ZcA5BmMtJsOkWOFRJQ0Jt0g1UwqXJJVAb+V+umH5Dfr8oh4EVP7VVg==", - "dev": true, - "requires": { - "reusify": "^1.0.4" - } - }, - "fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "dev": true, - "requires": { - "bser": "2.1.1" - } - }, - "file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "requires": { - "flat-cache": "^3.0.4" - } - }, - "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "requires": { - "to-regex-range": "^5.0.1" - } - }, - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", - "dev": true, - "requires": { - "flatted": "^3.1.0", - "rimraf": "^3.0.2" - } - }, - "flatted": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", - "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", - "dev": true - }, - "follow-redirects": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", - "dev": true - }, - "form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - } - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "optional": true - }, - "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true - }, - "gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true - }, - "get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true - }, - "get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true - }, - "get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true - }, - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "requires": { - "is-glob": "^4.0.3" - } - }, - "globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true - }, - "graceful-fs": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", - "dev": true - }, - "grapheme-splitter": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", - "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", - "dev": true - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true - }, - "human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true - }, - "ignore": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.1.tgz", - "integrity": "sha512-d2qQLzTJ9WxQftPAuEQpSPmKqzxePjzVbpAVv62AQ64NTL+wR4JkrVqR/LqFsFEUsHDAiId52mJteHDFuDkElA==", - "dev": true - }, - "import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "requires": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "dependencies": { - "resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true - } - } - }, - "import-local": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", - "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", - "dev": true, - "requires": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - } - }, - "imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true - }, - "is-core-module": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", - "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", - "dev": true, - "requires": { - "has": "^1.0.3" - } - }, - "is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "dev": true - }, - "is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "requires": { - "is-extglob": "^2.1.1" - } - }, - "is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true - }, - "is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true - }, - "is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "istanbul-lib-coverage": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", - "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", - "dev": true - }, - "istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "requires": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - } - }, - "istanbul-lib-report": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", - "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", - "dev": true, - "requires": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^3.0.0", - "supports-color": "^7.1.0" - } - }, - "istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "requires": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - } - }, - "istanbul-reports": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.5.tgz", - "integrity": "sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==", - "dev": true, - "requires": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - } - }, - "jest": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.3.1.tgz", - "integrity": "sha512-6iWfL5DTT0Np6UYs/y5Niu7WIfNv/wRTtN5RSXt2DIEft3dx3zPuw/3WJQBCJfmEzvDiEKwoqMbGD9n49+qLSA==", - "dev": true, - "requires": { - "@jest/core": "^29.3.1", - "@jest/types": "^29.3.1", - "import-local": "^3.0.2", - "jest-cli": "^29.3.1" - } - }, - "jest-changed-files": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.2.0.tgz", - "integrity": "sha512-qPVmLLyBmvF5HJrY7krDisx6Voi8DmlV3GZYX0aFNbaQsZeoz1hfxcCMbqDGuQCxU1dJy9eYc2xscE8QrCCYaA==", - "dev": true, - "requires": { - "execa": "^5.0.0", - "p-limit": "^3.1.0" - } - }, - "jest-circus": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.3.1.tgz", - "integrity": "sha512-wpr26sEvwb3qQQbdlmei+gzp6yoSSoSL6GsLPxnuayZSMrSd5Ka7IjAvatpIernBvT2+Ic6RLTg+jSebScmasg==", - "dev": true, - "requires": { - "@jest/environment": "^29.3.1", - "@jest/expect": "^29.3.1", - "@jest/test-result": "^29.3.1", - "@jest/types": "^29.3.1", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^0.7.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.3.1", - "jest-matcher-utils": "^29.3.1", - "jest-message-util": "^29.3.1", - "jest-runtime": "^29.3.1", - "jest-snapshot": "^29.3.1", - "jest-util": "^29.3.1", - "p-limit": "^3.1.0", - "pretty-format": "^29.3.1", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - } - }, - "jest-cli": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.3.1.tgz", - "integrity": "sha512-TO/ewvwyvPOiBBuWZ0gm04z3WWP8TIK8acgPzE4IxgsLKQgb377NYGrQLc3Wl/7ndWzIH2CDNNsUjGxwLL43VQ==", - "dev": true, - "requires": { - "@jest/core": "^29.3.1", - "@jest/test-result": "^29.3.1", - "@jest/types": "^29.3.1", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "import-local": "^3.0.2", - "jest-config": "^29.3.1", - "jest-util": "^29.3.1", - "jest-validate": "^29.3.1", - "prompts": "^2.0.1", - "yargs": "^17.3.1" - } - }, - "jest-config": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.3.1.tgz", - "integrity": "sha512-y0tFHdj2WnTEhxmGUK1T7fgLen7YK4RtfvpLFBXfQkh2eMJAQq24Vx9472lvn5wg0MAO6B+iPfJfzdR9hJYalg==", - "dev": true, - "requires": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.3.1", - "@jest/types": "^29.3.1", - "babel-jest": "^29.3.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.3.1", - "jest-environment-node": "^29.3.1", - "jest-get-type": "^29.2.0", - "jest-regex-util": "^29.2.0", - "jest-resolve": "^29.3.1", - "jest-runner": "^29.3.1", - "jest-util": "^29.3.1", - "jest-validate": "^29.3.1", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^29.3.1", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - } - }, - "jest-diff": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.3.1.tgz", - "integrity": "sha512-vU8vyiO7568tmin2lA3r2DP8oRvzhvRcD4DjpXc6uGveQodyk7CKLhQlCSiwgx3g0pFaE88/KLZ0yaTWMc4Uiw==", - "dev": true, - "requires": { - "chalk": "^4.0.0", - "diff-sequences": "^29.3.1", - "jest-get-type": "^29.2.0", - "pretty-format": "^29.3.1" - } - }, - "jest-docblock": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.2.0.tgz", - "integrity": "sha512-bkxUsxTgWQGbXV5IENmfiIuqZhJcyvF7tU4zJ/7ioTutdz4ToB5Yx6JOFBpgI+TphRY4lhOyCWGNH/QFQh5T6A==", - "dev": true, - "requires": { - "detect-newline": "^3.0.0" - } - }, - "jest-each": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.3.1.tgz", - "integrity": "sha512-qrZH7PmFB9rEzCSl00BWjZYuS1BSOH8lLuC0azQE9lQrAx3PWGKHTDudQiOSwIy5dGAJh7KA0ScYlCP7JxvFYA==", - "dev": true, - "requires": { - "@jest/types": "^29.3.1", - "chalk": "^4.0.0", - "jest-get-type": "^29.2.0", - "jest-util": "^29.3.1", - "pretty-format": "^29.3.1" - } - }, - "jest-environment-node": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.3.1.tgz", - "integrity": "sha512-xm2THL18Xf5sIHoU7OThBPtuH6Lerd+Y1NLYiZJlkE3hbE+7N7r8uvHIl/FkZ5ymKXJe/11SQuf3fv4v6rUMag==", - "dev": true, - "requires": { - "@jest/environment": "^29.3.1", - "@jest/fake-timers": "^29.3.1", - "@jest/types": "^29.3.1", - "@types/node": "*", - "jest-mock": "^29.3.1", - "jest-util": "^29.3.1" - } - }, - "jest-get-type": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.2.0.tgz", - "integrity": "sha512-uXNJlg8hKFEnDgFsrCjznB+sTxdkuqiCL6zMgA75qEbAJjJYTs9XPrvDctrEig2GDow22T/LvHgO57iJhXB/UA==", - "dev": true - }, - "jest-haste-map": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.3.1.tgz", - "integrity": "sha512-/FFtvoG1xjbbPXQLFef+WSU4yrc0fc0Dds6aRPBojUid7qlPqZvxdUBA03HW0fnVHXVCnCdkuoghYItKNzc/0A==", - "dev": true, - "requires": { - "@jest/types": "^29.3.1", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "fsevents": "^2.3.2", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.2.0", - "jest-util": "^29.3.1", - "jest-worker": "^29.3.1", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - } - }, - "jest-leak-detector": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.3.1.tgz", - "integrity": "sha512-3DA/VVXj4zFOPagGkuqHnSQf1GZBmmlagpguxEERO6Pla2g84Q1MaVIB3YMxgUaFIaYag8ZnTyQgiZ35YEqAQA==", - "dev": true, - "requires": { - "jest-get-type": "^29.2.0", - "pretty-format": "^29.3.1" - } - }, - "jest-matcher-utils": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.3.1.tgz", - "integrity": "sha512-fkRMZUAScup3txIKfMe3AIZZmPEjWEdsPJFK3AIy5qRohWqQFg1qrmKfYXR9qEkNc7OdAu2N4KPHibEmy4HPeQ==", - "dev": true, - "requires": { - "chalk": "^4.0.0", - "jest-diff": "^29.3.1", - "jest-get-type": "^29.2.0", - "pretty-format": "^29.3.1" - } - }, - "jest-message-util": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.3.1.tgz", - "integrity": "sha512-lMJTbgNcDm5z+6KDxWtqOFWlGQxD6XaYwBqHR8kmpkP+WWWG90I35kdtQHY67Ay5CSuydkTBbJG+tH9JShFCyA==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.3.1", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.3.1", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - } - }, - "jest-mock": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.3.1.tgz", - "integrity": "sha512-H8/qFDtDVMFvFP4X8NuOT3XRDzOUTz+FeACjufHzsOIBAxivLqkB1PoLCaJx9iPPQ8dZThHPp/G3WRWyMgA3JA==", - "dev": true, - "requires": { - "@jest/types": "^29.3.1", - "@types/node": "*", - "jest-util": "^29.3.1" - } - }, - "jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "requires": {} - }, - "jest-regex-util": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.2.0.tgz", - "integrity": "sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==", - "dev": true - }, - "jest-resolve": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.3.1.tgz", - "integrity": "sha512-amXJgH/Ng712w3Uz5gqzFBBjxV8WFLSmNjoreBGMqxgCz5cH7swmBZzgBaCIOsvb0NbpJ0vgaSFdJqMdT+rADw==", - "dev": true, - "requires": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.3.1", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.3.1", - "jest-validate": "^29.3.1", - "resolve": "^1.20.0", - "resolve.exports": "^1.1.0", - "slash": "^3.0.0" - } - }, - "jest-resolve-dependencies": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.3.1.tgz", - "integrity": "sha512-Vk0cYq0byRw2WluNmNWGqPeRnZ3p3hHmjJMp2dyyZeYIfiBskwq4rpiuGFR6QGAdbj58WC7HN4hQHjf2mpvrLA==", - "dev": true, - "requires": { - "jest-regex-util": "^29.2.0", - "jest-snapshot": "^29.3.1" - } - }, - "jest-runner": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.3.1.tgz", - "integrity": "sha512-oFvcwRNrKMtE6u9+AQPMATxFcTySyKfLhvso7Sdk/rNpbhg4g2GAGCopiInk1OP4q6gz3n6MajW4+fnHWlU3bA==", - "dev": true, - "requires": { - "@jest/console": "^29.3.1", - "@jest/environment": "^29.3.1", - "@jest/test-result": "^29.3.1", - "@jest/transform": "^29.3.1", - "@jest/types": "^29.3.1", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.2.0", - "jest-environment-node": "^29.3.1", - "jest-haste-map": "^29.3.1", - "jest-leak-detector": "^29.3.1", - "jest-message-util": "^29.3.1", - "jest-resolve": "^29.3.1", - "jest-runtime": "^29.3.1", - "jest-util": "^29.3.1", - "jest-watcher": "^29.3.1", - "jest-worker": "^29.3.1", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - } - }, - "jest-runtime": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.3.1.tgz", - "integrity": "sha512-jLzkIxIqXwBEOZx7wx9OO9sxoZmgT2NhmQKzHQm1xwR1kNW/dn0OjxR424VwHHf1SPN6Qwlb5pp1oGCeFTQ62A==", - "dev": true, - "requires": { - "@jest/environment": "^29.3.1", - "@jest/fake-timers": "^29.3.1", - "@jest/globals": "^29.3.1", - "@jest/source-map": "^29.2.0", - "@jest/test-result": "^29.3.1", - "@jest/transform": "^29.3.1", - "@jest/types": "^29.3.1", - "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.3.1", - "jest-message-util": "^29.3.1", - "jest-mock": "^29.3.1", - "jest-regex-util": "^29.2.0", - "jest-resolve": "^29.3.1", - "jest-snapshot": "^29.3.1", - "jest-util": "^29.3.1", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - } - }, - "jest-snapshot": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.3.1.tgz", - "integrity": "sha512-+3JOc+s28upYLI2OJM4PWRGK9AgpsMs/ekNryUV0yMBClT9B1DF2u2qay8YxcQd338PPYSFNb0lsar1B49sLDA==", - "dev": true, - "requires": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/traverse": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.3.1", - "@jest/transform": "^29.3.1", - "@jest/types": "^29.3.1", - "@types/babel__traverse": "^7.0.6", - "@types/prettier": "^2.1.5", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.3.1", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.3.1", - "jest-get-type": "^29.2.0", - "jest-haste-map": "^29.3.1", - "jest-matcher-utils": "^29.3.1", - "jest-message-util": "^29.3.1", - "jest-util": "^29.3.1", - "natural-compare": "^1.4.0", - "pretty-format": "^29.3.1", - "semver": "^7.3.5" - }, - "dependencies": { - "semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - } - } - }, - "jest-util": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.3.1.tgz", - "integrity": "sha512-7YOVZaiX7RJLv76ZfHt4nbNEzzTRiMW/IiOG7ZOKmTXmoGBxUDefgMAxQubu6WPVqP5zSzAdZG0FfLcC7HOIFQ==", - "dev": true, - "requires": { - "@jest/types": "^29.3.1", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - } - }, - "jest-validate": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.3.1.tgz", - "integrity": "sha512-N9Lr3oYR2Mpzuelp1F8negJR3YE+L1ebk1rYA5qYo9TTY3f9OWdptLoNSPP9itOCBIRBqjt/S5XHlzYglLN67g==", - "dev": true, - "requires": { - "@jest/types": "^29.3.1", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.2.0", - "leven": "^3.1.0", - "pretty-format": "^29.3.1" - }, - "dependencies": { - "camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true - } - } - }, - "jest-watcher": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.3.1.tgz", - "integrity": "sha512-RspXG2BQFDsZSRKGCT/NiNa8RkQ1iKAjrO0//soTMWx/QUt+OcxMqMSBxz23PYGqUuWm2+m2mNNsmj0eIoOaFg==", - "dev": true, - "requires": { - "@jest/test-result": "^29.3.1", - "@jest/types": "^29.3.1", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.3.1", - "string-length": "^4.0.1" - } - }, - "jest-worker": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.3.1.tgz", - "integrity": "sha512-lY4AnnmsEWeiXirAIA0c9SDPbuCBq8IYuDVL8PMm0MZ2PEs2yPvRA/J64QBXuZp7CYKrDM/rmNrc9/i3KJQncw==", - "dev": true, - "requires": { - "@types/node": "*", - "jest-util": "^29.3.1", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "dependencies": { - "supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "js-sdsl": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz", - "integrity": "sha512-dyBIzQBDkCqCu+0upx25Y2jGdbTGxE9fshMsCdK0ViOongpV+n5tXRcZY9v7CaVQ79AGS9KA1KHtojxiM7aXSQ==", - "dev": true - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "requires": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - } - }, - "jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "dev": true - }, - "json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, - "json5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", - "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", - "dev": true - }, - "kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true - }, - "leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true - }, - "levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "requires": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - } - }, - "lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "requires": { - "p-locate": "^4.1.0" - } - }, - "lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, - "requires": { - "semver": "^6.0.0" - } - }, - "makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "requires": { - "tmpl": "1.0.5" - } - }, - "merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, - "micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, - "requires": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" - } - }, - "mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true - }, - "mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "requires": { - "mime-db": "1.52.0" - } - }, - "mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true - }, - "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true - }, - "node-releases": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz", - "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==", - "dev": true - }, - "normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true - }, - "npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "requires": { - "path-key": "^3.0.0" - } - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "requires": { - "mimic-fn": "^2.1.0" - } - }, - "optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", - "dev": true, - "requires": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.3" - } - }, - "p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "requires": { - "yocto-queue": "^0.1.0" - } - }, - "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "requires": { - "p-limit": "^2.2.0" - }, - "dependencies": { - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - } - } - }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true - }, - "parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "requires": { - "callsites": "^3.0.0" - } - }, - "parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - } - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true - }, - "path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true - }, - "path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true - }, - "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true - }, - "pirates": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz", - "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==", - "dev": true - }, - "pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "requires": { - "find-up": "^4.0.0" - } - }, - "prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true - }, - "pretty-format": { - "version": "29.3.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.3.1.tgz", - "integrity": "sha512-FyLnmb1cYJV8biEIiRyzRFvs2lry7PPIvOqKVe1GCUEYg4YGmlx1qG9EJNMxArYm7piII4qb8UV1Pncq5dxmcg==", - "dev": true, - "requires": { - "@jest/schemas": "^29.0.0", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true - } - } - }, - "prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "requires": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - } - }, - "proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true - }, - "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true - }, - "queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true - }, - "react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true - }, - "regexpp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", - "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", - "dev": true - }, - "require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true - }, - "resolve": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", - "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", - "dev": true, - "requires": { - "is-core-module": "^2.9.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "requires": { - "resolve-from": "^5.0.0" - } - }, - "resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true - }, - "resolve.exports": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.0.tgz", - "integrity": "sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ==", - "dev": true - }, - "reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true - }, - "rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, - "run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "requires": { - "queue-microtask": "^1.2.2" - } - }, - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - }, - "shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "requires": { - "shebang-regex": "^3.0.0" - } - }, - "shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true - }, - "signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, - "sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true - }, - "slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - }, - "source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "requires": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true - }, - "stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "requires": { - "escape-string-regexp": "^2.0.0" - } - }, - "string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "dev": true, - "requires": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - } - }, - "string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - }, - "strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true - }, - "strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true - }, - "strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true - }, - "test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "requires": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - } - }, - "text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true - }, - "tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true - }, - "to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true - }, - "to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "requires": { - "is-number": "^7.0.0" - } - }, - "type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "requires": { - "prelude-ls": "^1.2.1" - } - }, - "type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true - }, - "type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true - }, - "update-browserslist-db": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", - "integrity": "sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==", - "dev": true, - "requires": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" - } - }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "requires": { - "punycode": "^2.1.0" - } - }, - "v8-to-istanbul": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz", - "integrity": "sha512-74Y4LqY74kLE6IFyIjPtkSTWzUZmj8tdHT9Ii/26dvQ6K9Dl2NbEfj0XgU2sHCtKgt5VupqhlO/5aWuqS+IY1w==", - "dev": true, - "requires": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^1.6.0" - }, - "dependencies": { - "convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true - } - } - }, - "walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "requires": { - "makeerror": "1.0.12" - } - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - }, - "word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true - }, - "wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - } - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "requires": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - } - }, - "y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "yargs": { - "version": "17.6.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.6.2.tgz", - "integrity": "sha512-1/9UrdHjDZc0eOU0HxOHoS78C69UD3JRMvzlJ7S79S2nTaWRA/whGCTV8o9e/N/1Va9YIV7Q4sOxD8VV4pCWOw==", - "dev": true, - "requires": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - } - }, - "yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true - }, - "yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 1acff2b..0000000 --- a/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "xmas", - "version": "1.0.0", - "description": "xM API SDK (javascript)", - "main": "src/index.js", - "files": [ - "src/**/*.js", - "!src/**/*.test.js" - ], - "scripts": { - "test": "jest", - "sandboxConfig": "cp sandbox/configTemplate.json sandbox/config.json", - "sandboxReset": "cp sandbox/template.js sandbox/index.js", - "sandbox": "node sandbox/", - "lint": "eslint **/*.js" - }, - "keywords": [ - "xMatters", - "API", - "SDK", - "xmApi" - ], - "author": "jfx", - "license": "ISC", - "devDependencies": { - "axios": "^1.2.1", - "eslint": "^8.29.0", - "jest": "^29.3.1" - } -} diff --git a/sandbox/configTemplate.json b/sandbox/configTemplate.json deleted file mode 100644 index 6340223..0000000 --- a/sandbox/configTemplate.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "hostname": "https://yourOrg.xmatters.com", - "userAgent": { - "name": "yourAppName", - "version": "yourAppVersion" - }, - "username": "", - "password": "", - "authorizationCode": "", - "accessToken": "", - "refreshToken": "", - "clientId": "" -} \ No newline at end of file diff --git a/sandbox/template.js b/sandbox/template.js deleted file mode 100644 index 41aaf1e..0000000 --- a/sandbox/template.js +++ /dev/null @@ -1,60 +0,0 @@ -const Xmas = require('../src'); -const config = require('./config.json'); - -const configUsernamePasswordOnly = { - hostname: config.hostname, - userAgent: config.userAgent, - username: config.username, - password: config.password, - noisy: true -}; - -// const configAuthorizationCodedOnly = { -// hostname: config.hostname, -// userAgent: config.userAgent, -// authorizationCode: config.authorizationCode, -// }; - -// const configTokensOnlyWithoutClientId = { -// hostname: config.hostname, -// userAgent: config.userAgent, -// accessToken: config.accessToken, -// refreshToken: config.refreshToken, -// onTokensChange: console.log -// }; - -// const configTokensOnlyWithClientId = { -// ...configTokensOnlyWithoutClientId, -// clientId: config.clientId, -// }; - -const buildHttpClient = (resolve) => ({ - sendRequest: () => { - return resolve - ? Promise.resolve({ data: { id: 'uuid' } }) - : Promise.reject({ statusCode: 555, data: { bim: 'badaboom' }}); - }, - successAdapter: (res) => res.data, - failureAdapter: (e) => { throw { status: e.statusCode, payload: e.data }; } -}); - -const xmas = new Xmas({ - ...configUsernamePasswordOnly, - httpClient: buildHttpClient(true) -}); - -// xmas.getOauthTokens.byUsernamePassword() -// .then(() => xmas.people.get()) -// .catch(console.log); - -// const aNuGroup = { targetName: 'someNewGroup' }; -// xmas.people.get() -// .then(() => xmas.groups.create(aNuGroup)) -// .then((newGroup) => xmas.groups.delete(newGroup.id)) -// .then(() => xmas.groups.get({ headers: { total: 'override' }, queryParams: { firstName: 'Bob' } })) -// .then(() => xmas.people.getDevicesOf('persId', { firstName: 'Bob' })) -// .then(() => xmas.get({ endpoint: 'copOut' })) -// .catch(console.log); - -xmas.people.get() - .catch(console.log); diff --git a/src/RequestBuilder/CoreBuilder.js b/src/RequestBuilder/CoreBuilder.js deleted file mode 100644 index c1801b2..0000000 --- a/src/RequestBuilder/CoreBuilder.js +++ /dev/null @@ -1,62 +0,0 @@ -class CoreBuilder { - constructor(endpoint, requestor) { - this.endpoint = endpoint ? endpoint.toLowerCase() : ''; - this.requestor = requestor; - } - - send(params) { - // allow overriding endpoint - return this.requestor.execute({ endpoint: this.endpoint, ...params }); - } - - get(params) { - return this.send({ ...params, method: 'GET' }); - } - - post(params) { - return this.send({ ...params, method: 'POST' }); - } - - delete(id) { - return this.send({ pathParams: [id], method: 'DELETE' }); - } - - /** - * With great power comes responsability - */ - depaginate(ogParams) { - const that = this; - const response = { - count: 0, - total: 0, - data: [] - }; - function depaginate(params) { - return that.get(params) - .then(xmRes => { - response.total = xmRes.total; - response.count = xmRes.total; - response.data.push(...xmRes.data); - if (xmRes.data.links.next) { - const { next } = xmRes.data.links; - const resQueryString = next.split('?')[1]; - const resQueryParams = new URLSearchParams(resQueryString); - const resOffset = resQueryParams.get('offset'); - const nextParams = { - ...params, - queryString: { - ...params.queryString, - limit: 1000, - offset: resOffset - } - }; - return depaginate(nextParams); - } - return response; - }); - } - return depaginate(ogParams); - } -} - -module.exports = CoreBuilder; diff --git a/src/RequestBuilder/index.js b/src/RequestBuilder/index.js deleted file mode 100644 index fdd5d21..0000000 --- a/src/RequestBuilder/index.js +++ /dev/null @@ -1,37 +0,0 @@ -const CoreBuilder = require('./CoreBuilder'); - -/** - * Generate an object giving access to a collection of methods reusable across all endpoints - * eg: getById can be used both in xmas.people.getById and xmas.groups.getById - * @param {String} endpoint The xM API endpoint the request builder should default to. - * eg: 'people' will produce requests with url: https://eg.xmatters.com/api/xm/1/people - * @param {Object} requestor A reference to a Requestor initialized with the config before anything - * @returns {Object} a RequestBuilder with a collection of methods reusable across all endpoints - */ -class RequestBuilder extends CoreBuilder { - constructor(endpoint, requestor) { - super(endpoint, requestor); - } - getById(id, queryParams) { - return this.get({ pathParams: [id], queryParams }); - } - - search(searchTerm, queryParams) { - return this.get({ queryParams: { ...queryParams, search: searchTerm } }); - } - - create(resource) { - return this.post({ data: resource }); - } - - update(id, resource) { - return this.post({ data: { ...resource, id } }); - } - - // Common to both people and groups, so it belongs here - getSupervisorsOf(id, queryParams) { - return this.get({ pathParams: [id, 'supervisors'], queryParams }); - } -} - -module.exports = RequestBuilder; diff --git a/src/Requestor/index.js b/src/Requestor/index.js deleted file mode 100644 index 1dd02c4..0000000 --- a/src/Requestor/index.js +++ /dev/null @@ -1,181 +0,0 @@ -const { shapeRequest, handleAxiosError, handleAxiosRes } = require('./utils'); - -class Requestor { - constructor(config) { - this.httpClient = config.httpClient; - this.userAgent = config.userAgent; - this.hostname = config.hostname; - this.apiPath = '/api/xm/1'; - this.username = config.username; - this.password = config.password; - this.maxAttempts = config.maxAttempts || 3; - this.clientId = config.clientId; - this.refreshToken = config.refreshToken; - this.accessToken = config.accessToken; - this.onTokensChange = config.onTokensChange; - this.noisy = config.noisy; - } - - debug(...args) { - if (this.noisy) { - console.log(...args); - } - } - - send(request) { - this.debug(request); - if (this.httpClient) { - return this.httpClient.sendRequest(request) - .then(this.httpClient.successAdapter) - .catch(this.httpClient.failureAdapter); - } - const { default: axios } = require('axios'); - return axios(request) - .then(handleAxiosRes) - .catch(handleAxiosError); - } - - execute(params) { - const request = shapeRequest({ - userAgent: this.userAgent, - hostname: this.hostname, - apiPath: this.apiPath, - username: this.username, - password: this.password, - accessToken: this.getAccessToken(), - ...params // allow overriding anything - }); - params.attemptNumber = params.attemptNumber ? (params.attemptNumber + 1) : 1; - return this.send(request) - .then(res => { - this.debug(res); - return res; - }) - .catch(this.getRetryOrRethrow(params)); - } - - getRetryOrRethrow(ogParams) { - const that = this; - return function retryOrRethrow(e) { - that.debug(e, { attemptNumber: ogParams.attemptNumber }); - if (ogParams.attemptNumber < that.maxAttempts) { - if (e.status === 401 && !that.getRefreshToken()) { - throw e.payload || e; - } - // TODO: xmApi doesn't invalidate previous tokens on refresh so we're good here - // but it'd be nice one of these days to figure out a way for 2 concurrent requests NOT to - // initiate a refresh at the same time - // For example: when the accessToken is expired and - // the consumer does Promise.all([xmas.people.getAll(), xmas.groups.getAll()]) - const reAuthIfNecessary = (e.status === 401) ? that.refreshTokens() : Promise.resolve(); - return reAuthIfNecessary - .then(() => that.execute(ogParams)); - } - // e.payload is a clean and predictable error from the actual API response - // e is a failsafe for any unforseen errors. Likely development errors or network issues - throw e.payload || e; - }; - } - - handleTokensChange(oAuthXmApiResponse) { - const { access_token: accessToken, refresh_token: refreshToken } = oAuthXmApiResponse; - this.setAccessToken(accessToken); - this.setRefreshToken(refreshToken); - if (typeof this.onTokensChange === 'function') { - try { - this.onTokensChange({ - accessToken: this.getAccessToken(), - refreshToken: this.getRefreshToken() - }); - } catch (error) { - // people don't read doc, so maybe always log? - this.debug('Provided onTokensChange function must catch and handle its own errors'); - this.debug(error); - } - } - return { ...oAuthXmApiResponse, clientId: this.getClientId() }; - } - - getOauthTokens(oauthPayload) { - return this.execute({ - method: 'POST', - endpoint: 'oauth2', - pathParams: ['token'], - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - data: oauthPayload - }) - .then((oAuthXmApiResponse) => this.handleTokensChange(oAuthXmApiResponse)); - // ".then" doesn't work well with "this" - // can't do .then(this.handleTokensChange) - } - - refreshTokens() { - // Might be useful to throw some: - // "you're attempting to refresh OAuth tokens but you did not initiate xmas with a refreshToken" - const oauthPayload = new URLSearchParams({ - // eslint-disable-next-line camelcase - grant_type: 'refresh_token', - // eslint-disable-next-line camelcase - client_id: this.getClientId(), - // eslint-disable-next-line camelcase - refresh_token: this.getRefreshToken() - }).toString(); - return this.getOauthTokens(oauthPayload); - } - - fetchClientId() { - return this.execute({ method: 'GET', endpoint: 'organization' }) - .then(({ customerId }) => this.setClientId(customerId)); - } - - byUsernamePassword() { - return this.getClientId() ? Promise.resolve() : this.fetchClientId() - .then(() => { - const oauthPayload = new URLSearchParams({ - // eslint-disable-next-line camelcase - grant_type: 'password', - // eslint-disable-next-line camelcase - client_id: this.getClientId(), - username: this.username, - password: this.password - }).toString(); - return this.getOauthTokens(oauthPayload); - }); - } - - // GETTERS and SETTERS (only for properties that get modified on the fly) - // eg: hostname comes from the config and is read only, so no need - // but the clientId might not be provided in the config and might be dynamically loaded so... need - - setClientId(clientId) { - this.clientId = clientId; - return clientId; - } - - getClientId() { - return this.clientId; - } - - setAccessToken(accessToken) { - this.accessToken = accessToken; - return accessToken; - } - - getAccessToken() { - return this.accessToken; - } - - setRefreshToken(refreshToken) { - this.refreshToken = refreshToken; - return refreshToken; - } - - getRefreshToken() { - return this.refreshToken; - } -} - - -module.exports = Requestor; diff --git a/src/Requestor/utils.js b/src/Requestor/utils.js deleted file mode 100644 index e4993c9..0000000 --- a/src/Requestor/utils.js +++ /dev/null @@ -1,103 +0,0 @@ -const nodePath = require('path'); -const packageJson = require('../../package.json'); - -function buildUrl({ hostname, apiPath, endpoint, pathParams, queryParams }) { - const [protocol, rest] = hostname.split('//'); - const domain = rest || protocol; - const baseUrl = nodePath.join(domain, apiPath, endpoint); - const path = pathParams ? pathParams.join('/') : ''; - const query = queryParams ? new URLSearchParams(queryParams).toString() : ''; - let url = baseUrl; - if (path) { - url = nodePath.join(baseUrl, path); - } - if (query) { - url += '?' + query; - } - return (/^https?:\/\//.test(protocol) ? protocol : 'https://') + url; -} - -function buildAuth({ username, password, accessToken }) { - if (accessToken) { - return 'Bearer ' + accessToken; - } - return 'Basic ' + Buffer.from(username + ':' + password).toString('base64'); -} -function buildUserAgentHeader(params) { - const { name, version } = params.userAgent; - const agentVersion = version ? ` (${version})` : ''; - return `${name}${agentVersion} | xmApiSdkJs (${packageJson.version})`; -} - -const buildHeaders = (params) => { - // allow consumer to override default headers with null - const headers = params.headers !== undefined ? params.headers : { - 'Content-Type': 'application/json; charset=utf-8', - 'Authorization': buildAuth(params) - }; - const userAgent = buildUserAgentHeader(params); - return headers ? { ...headers, 'User-Agent': userAgent } : headers; -}; - -const shapeRequest = (params) => { - const req = { - method: params.method, - url: buildUrl(params) - }; - const headers = buildHeaders(params); - if (headers) { - req.headers = headers; - } - if (params.data) { - req.data = typeof params.data === 'string' - ? params.data - : JSON.stringify(params.data); - } - return req; -}; - -const handleAxiosError = (e) => { - const humanReadableMessage = e.response - ? `xM API responded with ${e.response.status} ${e.response.statusText}` - : 'Something went wrong and no response was received from xM API'; - const error = new Error(humanReadableMessage); - error.status = e.response?.status; - error.payload = e.response?.data; - throw error; -}; - -const handleAxiosRes = (res) => res.data; - -const validateConfig = (config) => { - if (!config) { - throw new Error('Missing config'); - } - const { userAgent, username, password, refreshToken, clientId } = config; - const requiredFields = ['hostname', 'userAgent']; - const missing = requiredFields.reduce((missing, k) => { - return config[k] ? missing : missing.concat(k); - }, []); - if (missing.length > 0) { - throw new Error('config missing ' + missing.join(', ')); - } - if (!userAgent.name) { - throw new Error('config.userAgent missing name'); - } - if (username || password) { - if (!username || !password) { - throw new Error('config requires both username and password'); - } - } - if (clientId || refreshToken) { - if (!clientId || !refreshToken) { - throw new Error('config requires both clientId and refreshToken'); - } - } -}; - -module.exports = { - shapeRequest, - handleAxiosError, - handleAxiosRes, - validateConfig -}; diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 3cb1dcd..0000000 --- a/src/index.js +++ /dev/null @@ -1,22 +0,0 @@ -const RequestBuilder = require('./RequestBuilder'); -const Requestor = require('./Requestor'); -const { validateConfig } = require('./Requestor/utils'); -const Groups = require('./resources/Groups'); -const People = require('./resources/People'); - -class Xmas extends RequestBuilder { - constructor(config) { - validateConfig(config); - // There is no getting smart with the requestor - // the same reference must be used everywhere - const requestor = new Requestor(config); - super(null, requestor); // This allows a cop out such as xmas.get(anythingYouWant); - this.people = new People(requestor); - this.groups = new Groups(requestor); - this.getOauthTokens = { - byUsernamePassword: () => requestor.byUsernamePassword() - }; - } -} - -module.exports = Xmas; diff --git a/src/resources/Groups.js b/src/resources/Groups.js deleted file mode 100644 index f73476c..0000000 --- a/src/resources/Groups.js +++ /dev/null @@ -1,19 +0,0 @@ -const RequestBuilder = require('../RequestBuilder'); - -// technically this whole file is useless -// because there are no methods unique to groups -// xmas could build its this.groups with: new RequestBuilder('groups', requestor) -// Having this here is an attempt at futureproofing -// It's better to have other contributors copy-paste a tried-and-true pattern - -/** - * Generate an object giving access to a collection of methods strictly specific to group resources - * @param {Object} requestor A reference to a Requestor initialized with the config before anything - */ -class Groups extends RequestBuilder { - constructor(requestor) { - super('Groups', requestor); - } -} - -module.exports = Groups; diff --git a/src/resources/People.js b/src/resources/People.js deleted file mode 100644 index 93b0089..0000000 --- a/src/resources/People.js +++ /dev/null @@ -1,41 +0,0 @@ -const RequestBuilder = require('../RequestBuilder'); - -class People extends RequestBuilder { - constructor(requestor) { - super('People', requestor); - } - - getDevicesOf(personId, queryParams) { - return this.get({ pathParams: [personId, 'devices'], queryParams }); - } - - getGroupsOf(personId, queryParams) { - return this.get({ pathParams: [personId, 'group-memberships'], queryParams }); - } - - searchByFirstName(firstName, queryParams = {}) { - return this.get({ queryParams: { firstName, ...queryParams } }); - } - - searchByLastName(lastName, queryParams = {}) { - return this.get({ queryParams: { lastName, ...queryParams } }); - } - - searchByTargetName(targetName, queryParams = {}) { - return this.get({ queryParams: { targetName, ...queryParams } }); - } - - searchByWebLogin(webLogin, queryParams = {}) { - return this.get({ queryParams: { webLogin, ...queryParams } }); - } - - searchByPhoneNumber(phoneNumber, queryParams = {}) { - return this.get({ queryParams: { phoneNumber, ...queryParams } }); - } - - searchByEmail(emailAddress, queryParams = {}) { - return this.get({ queryParams: { emailAddress, ...queryParams } }); - } -} - -module.exports = People; diff --git a/src/resources/groups.test.js b/src/resources/groups.test.js deleted file mode 100644 index 86f0388..0000000 --- a/src/resources/groups.test.js +++ /dev/null @@ -1,199 +0,0 @@ -jest.mock('axios'); -jest.mock('../../package.json', () => ({ version: '8.8.8' })); -const { default: axios } = require('axios'); -const Xmas = require('..'); - -const config = { - hostname: 'https://test.xmatters.com', - userAgent: { name: 'Unit tests', version: '5.5.5' }, - username: 'unit', - password: 'test', - noisy: false // default, but it's handy to have it togglable right here when drafting tests -}; - -const xmas = new Xmas(config); - -const buildExpectedRequest = ({ headers, method, url, data }) => ({ - headers: headers || { - 'Content-Type': 'application/json; charset=utf-8', - Authorization: 'Basic dW5pdDp0ZXN0', - 'User-Agent': 'Unit tests (5.5.5) | xmApiSdkJs (8.8.8)' - }, - method, - url, - data -}); - -const xmApiRes = { no: 'nock', jest: 'ftw' }; -const queryParams = { anything: 'goes' }; -const nuGroup = { targetName: ' let them shoot themselves in the feet ' }; - -beforeEach(() => { - axios.mockClear(); - axios.mockImplementation(() => Promise.resolve({ data: xmApiRes })); -}); - -describe('Xmas', () => { - describe('groups', () => { - test('get', () => xmas.groups.get({ queryParams }) - .then((res) => { - expect(res).toBe(xmApiRes); - expect(axios).toHaveBeenCalledWith(buildExpectedRequest({ - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups?anything=goes' - })); - }) - ); - test('getById', () => xmas.groups.getById('groupId', queryParams) - .then((res) => { - expect(res).toBe(xmApiRes); - expect(axios).toHaveBeenCalledWith(buildExpectedRequest({ - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups/groupId?anything=goes' - })); - }) - ); - test('search', () => xmas.groups.search('term', queryParams) - .then((res) => { - expect(res).toBe(xmApiRes); - expect(axios).toHaveBeenCalledWith(buildExpectedRequest({ - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups?anything=goes&search=term' - })); - }) - ); - test('getSupervisorsOf', () => xmas.groups.getSupervisorsOf('groupId', queryParams) - .then((res) => { - expect(res).toBe(xmApiRes); - expect(axios).toHaveBeenCalledWith(buildExpectedRequest({ - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups/groupId/supervisors?anything=goes' - })); - }) - ); - test('post', () => xmas.groups.post({ data: nuGroup }) - .then((res) => { - expect(res).toBe(xmApiRes); - expect(axios).toHaveBeenCalledWith(buildExpectedRequest({ - method: 'POST', - url: 'https://test.xmatters.com/api/xm/1/groups', - data: JSON.stringify(nuGroup) - })); - }) - ); - test('create', () => xmas.groups.create(nuGroup) - .then((res) => { - expect(res).toBe(xmApiRes); - expect(axios).toHaveBeenCalledWith(buildExpectedRequest({ - method: 'POST', - url: 'https://test.xmatters.com/api/xm/1/groups', - data: JSON.stringify(nuGroup) - })); - }) - ); - test('update', () => xmas.groups.update('someId', { more: 'things' }) - .then((res) => { - expect(res).toBe(xmApiRes); - expect(axios).toHaveBeenCalledWith(buildExpectedRequest({ - method: 'POST', - url: 'https://test.xmatters.com/api/xm/1/groups', - data: JSON.stringify({ more: 'things', id: 'someId' }) - })); - }) - ); - test('delete', () => xmas.groups.delete(nuGroup.targetName) - .then((res) => { - expect(res).toBe(xmApiRes); - expect(axios).toHaveBeenCalledWith(buildExpectedRequest({ - method: 'DELETE', - url: 'https://test.xmatters.com/api/xm/1/groups/ let them shoot themselves in the feet ' - })); - }) - ); - describe('On errors', () => { - test('when xmApi responded, the sdk returns the api response', () => { - const axiosTypicalErrorResponse = { - response: { status: 404, data: xmApiRes } - }; - axios.mockImplementation(() => Promise.reject(axiosTypicalErrorResponse)); - return xmas.groups.getById('groupId', queryParams) - .catch(e => { - expect(e).toBe(xmApiRes); - expect(axios).toHaveBeenCalledTimes(3); - expect(axios).toHaveBeenCalledWith(buildExpectedRequest({ - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups/groupId?anything=goes' - })); - }); - }); - test('when xmApi did not respond, the sdk returns the error', () => { - axios.mockImplementation(() => Promise.reject('anything')); - return xmas.groups.getById('groupId', queryParams) - .catch(e => { - expect(e).toStrictEqual(new Error('Something went wrong and no response was received from xM API')); - expect(axios).toHaveBeenCalledTimes(3); - expect(axios).toHaveBeenCalledWith(buildExpectedRequest({ - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups/groupId?anything=goes' - })); - }); - }); - }); - }); - test('send', () => { - const params = { - endpoint: 'override', - method: 'everything', - pathParams: ['however'], - queryParams: { we: 'like'}, - hostname: 'andImean', - apiPath: 'really/anything', - accessToken: 'weWant' - }; - return xmas.send(params) - .then((res) => { - expect(res).toBe(xmApiRes); - expect(axios).toHaveBeenCalledWith(buildExpectedRequest({ - headers: { - 'Content-Type': 'application/json; charset=utf-8', - Authorization: 'Bearer weWant', - 'User-Agent': 'Unit tests (5.5.5) | xmApiSdkJs (8.8.8)' - }, - method: params.method, - url: 'https://andImean/really/anything/override/however?we=like' - })); - }); - }); - test('Auto add userAgent when consumer overrides them with an object', () => { - const params = { - headers: { custom: 'heddaz' } - }; - return xmas.groups.get(params) - .then((res) => { - expect(res).toBe(xmApiRes); - expect(axios).toHaveBeenCalledWith(buildExpectedRequest({ - headers: { - 'User-Agent': 'Unit tests (5.5.5) | xmApiSdkJs (8.8.8)', - ...params.headers - }, - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups' - })); - }); - }); - test('Allow requests with NO headers', () => { - const params = { - headers: null - }; - const expectedReq = buildExpectedRequest({ - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups' - }); - delete expectedReq.headers; - return xmas.groups.get(params) - .then((res) => { - expect(res).toBe(xmApiRes); - expect(axios).toHaveBeenCalledWith(expectedReq); - }); - }); -}); From f0ef959deadbd9cf5c0d70af76a09230074ab1f7 Mon Sep 17 00:00:00 2001 From: Johan Date: Mon, 19 May 2025 18:17:23 -0700 Subject: [PATCH 002/101] deno Establishing a base to start from --- .gitignore | 3 + LICENSE | 21 ++++++ README.md | 6 +- deno.json | 33 +++++++++ deno.lock | 23 +++++++ mod.ts | 10 +++ sandbox/index.ts | 23 +++++++ src/api/base.ts | 79 +++++++++++++++++++++ src/api/groups/index.ts | 104 ++++++++++++++++++++++++++++ src/api/groups/types.ts | 99 +++++++++++++++++++++++++++ src/errors.ts | 24 +++++++ src/http/default_client.ts | 31 +++++++++ src/types.ts | 29 ++++++++ src/xmas.ts | 137 +++++++++++++++++++++++++++++++++++++ 14 files changed, 619 insertions(+), 3 deletions(-) create mode 100644 LICENSE create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 mod.ts create mode 100644 sandbox/index.ts create mode 100644 src/api/base.ts create mode 100644 src/api/groups/index.ts create mode 100644 src/api/groups/types.ts create mode 100644 src/errors.ts create mode 100644 src/http/default_client.ts create mode 100644 src/types.ts create mode 100644 src/xmas.ts diff --git a/.gitignore b/.gitignore index e69de29..c6a44af 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,3 @@ +sandbox/credentials.ts +dist/ +node_modules/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..21ad091 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 xMatters + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index d1807d2..bbf208e 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ const config = { const xmas = new Xmas(config); -// Add a new group to your xMatters instance: -const nuGroup = { targetName: 'API developers' }; -xmas.groups.create(nuGroup) +// Create a new group in your xMatters instance: +const group = { targetName: 'API developers' }; +xmas.groups.create(group) .then(handleSuccess) .catch(handleError); ``` diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..85712ea --- /dev/null +++ b/deno.json @@ -0,0 +1,33 @@ +{ + "name": "@jfx/xmas", + "version": "1.0.0", + "license": "MIT", + "exports": { + ".": "./mod.ts" + }, + "tasks": { + "test": "deno test", + "check": "deno check mod.ts" + }, + "publish": { + "exclude": ["tests/"], + "include": ["src/", "mod.ts", "README.md", "LICENSE"] + }, + "imports": { + "@std/assert": "jsr:@std/assert@1" + }, + "compilerOptions": { + "strict": true, + "lib": ["deno.window", "deno.ns"] + }, + "fmt": { + "files": { + "include": ["src/", "tests/", "mod.ts"] + }, + "options": { + "lineWidth": 100, + "indentWidth": 2, + "singleQuote": true + } + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..53c3964 --- /dev/null +++ b/deno.lock @@ -0,0 +1,23 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@1": "1.0.7", + "jsr:@std/internal@^1.0.5": "1.0.5" + }, + "jsr": { + "@std/assert@1.0.7": { + "integrity": "64ce9fac879e0b9f3042a89b3c3f8ccfc9c984391af19e2087513a79d73e28c3", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/internal@1.0.5": { + "integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba" + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@1" + ] + } +} diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..9f3df88 --- /dev/null +++ b/mod.ts @@ -0,0 +1,10 @@ +export { Xmas } from "./src/xmas.ts"; +export type { XmasConfig } from "./src/types.ts"; +export * from "./src/errors.ts"; +export type { + Group, + CreateGroupInput, + UpdateGroupInput, + GroupsSearchParams, + GroupResponse, +} from "./src/api/groups/types.ts"; diff --git a/sandbox/index.ts b/sandbox/index.ts new file mode 100644 index 0000000..9f03a6e --- /dev/null +++ b/sandbox/index.ts @@ -0,0 +1,23 @@ +import { Xmas } from '../mod.ts'; +import config from './credentials.ts'; + +const xmas = new Xmas(config.basicAuth); + +xmas.groups + .find() + .then((res) => { + console.log(`Total Groups: ${res.total}`); + console.log(`Groups Count: ${res.count}`); + console.log('-------------------------'); + return res.data.forEach((group) => { + console.log(`Group ID: ${group.id}`); + console.log(`Group Name: ${group.targetName}`); + console.log(`Group Description: ${group.description}`); + console.log('-------------------------'); + console.log(`RAWDOG: ${JSON.stringify(group, null, 2)}`); + console.log('-------------------------'); + }); + } +).catch((err) => { + console.error('Error fetching groups:', err); +}); diff --git a/src/api/base.ts b/src/api/base.ts new file mode 100644 index 0000000..09bbffc --- /dev/null +++ b/src/api/base.ts @@ -0,0 +1,79 @@ +import type { HttpClient } from "../types.ts"; + +export abstract class BaseApi { + private static readonly API_VERSION = "/api/xm/1"; + + constructor( + private readonly client: HttpClient, + private readonly baseUrl: string, + private readonly resourcePath: string, + ) { + if (!resourcePath.startsWith("/")) { + throw new Error("Resource path must start with '/'"); + } + } + + private buildPath(pathOrParams?: string | Record): string { + // If it's search params or no path, return base resource path + if (!pathOrParams || typeof pathOrParams !== 'string') { + return `${BaseApi.API_VERSION}${this.resourcePath}`; + } + // Otherwise append the path segment + return `${BaseApi.API_VERSION}${this.resourcePath}/${pathOrParams}`; + } + + protected async request( + method: string, + path: string, + data?: unknown, + params?: Record + ): Promise { + const url = new URL(this.buildPath(path), this.baseUrl); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + url.searchParams.append(key, value); + } + }); + } + try { + const response = await this.client.sendRequest({ + method, + url: url.toString(), + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + data: data ? JSON.stringify(data) : undefined, + }); + return this.client.successAdapter(response) as T; + } catch (error) { + throw this.client.failureAdapter(error); + } + } + + protected get(pathOrParams?: string | Record): Promise { + const [path, params] = typeof pathOrParams === 'string' + ? [pathOrParams, undefined] + : [undefined, pathOrParams as Record]; + return this.request("GET", path!, undefined, params); + } + + protected post(pathOrData?: string | unknown, data?: unknown): Promise { + const [path, payload] = typeof pathOrData === 'string' + ? [pathOrData, data] + : ['', pathOrData]; + return this.request("POST", path, payload); + } + + protected put(pathOrData?: string | unknown, data?: unknown): Promise { + const [path, payload] = typeof pathOrData === 'string' + ? [pathOrData, data] + : ['', pathOrData]; + return this.request("PUT", path, payload); + } + + protected delete(path: string): Promise { + return this.request("DELETE", path); + } +} diff --git a/src/api/groups/index.ts b/src/api/groups/index.ts new file mode 100644 index 0000000..e8fbc7f --- /dev/null +++ b/src/api/groups/index.ts @@ -0,0 +1,104 @@ +import { BaseApi } from "../base.ts"; +import type { HttpClient } from "../../types.ts"; +import type { + Group, + CreateGroupInput, + UpdateGroupInput, + GroupsSearchParams, + GroupResponse, + GroupMemberResponse, +} from "./types.ts"; + +export class Groups extends BaseApi { + constructor(client: HttpClient, baseUrl: string) { + super(client, baseUrl, "/groups"); + } + + /** + * Get all the members of a specific group + * @param groupId The ID of the group + * @returns A promise that resolves to the list of members in the group + * @example + * ```ts + * const members = await xm.groups.getMembers("dev-team"); + * console.log(members); + * ``` + * @throws {Error} If the request fails + */ + getMembers(groupId: string): Promise { + return this.get(`${groupId}/members`); + } + + /** + * Get a specific group by ID or targetName + * @param id The ID or targetName of the group + * @returns A promise that resolves to the group object + * @example + * ```ts + * const group = await xm.groups.getById("dev-team"); + * console.log(group); + * ``` + * @throws {Error} If the request fails + */ + getById(id: string): Promise { + return this.get(id); + } + + /** + * Search for groups using provided criteria + * @param params Optional search parameters including offset and limit for pagination + * @returns A promise that resolves to the group response object + * @example + * ```ts + * const response = await xm.groups.find({ searchValue: "Dev" }); + * console.log(response); + * ``` + * @throws {Error} If the request fails + */ + find(params?: GroupsSearchParams): Promise { + const searchParams: Record = {}; + if (params?.searchValue) searchParams.searchValue = params.searchValue; + if (params?.offset !== undefined) searchParams.offset = params.offset.toString(); + if (params?.limit !== undefined) searchParams.limit = params.limit.toString(); + if (params?.propertiesToReturn?.length) { + searchParams.propertiesToReturn = params.propertiesToReturn.join(","); + } + return this.get(searchParams); + } + + /** Create a new group */ + create(input: CreateGroupInput): Promise { + return this.post(input); + } + + /** + * Update an existing group + * @param id The group ID or targetName + */ + update(id: string, input: UpdateGroupInput): Promise { + return this.put(id, input); + } + + /** + * Delete a group + * @param id The group ID or targetName + */ + remove(id: string): Promise { + return this.delete(id); + } + + /** Add members to a group */ + addMembers(groupId: string, memberIds: string[]): Promise { + return this.post(`${groupId}/members`, { data: memberIds }); + } + + /** Remove members from a group */ + removeMembers(groupId: string, memberIds: string[]): Promise { + // xM API doesn't have a bulk delete, so we need to delete one at a time + return Promise.all( + memberIds.map(memberId => + this.delete(`${groupId}/members/${memberId}`) + ) + ).then(() => {}); + } +} diff --git a/src/api/groups/types.ts b/src/api/groups/types.ts new file mode 100644 index 0000000..86a7c74 --- /dev/null +++ b/src/api/groups/types.ts @@ -0,0 +1,99 @@ +export interface Group { + id: string; + targetName: string; + recipientType: "GROUP"; + status: "ACTIVE" | "INACTIVE"; + supervisors?: string[]; + observers?: string[]; + description?: string; + externallyOwned?: boolean; + allowDuplicates?: boolean; + useDefaultDevices?: boolean; + observedByAll?: boolean; + externalKey?: string; + site?: { + id: string; + name: string; + links: { + self: string; + }; + }; + links: { + self: string; + }; + created: string; + groupType: "ON_CALL" | "DYNAMIC" | "BROADCAST"; +} + +export interface CreateGroupInput { + targetName: string; + supervisors?: string[]; + observers?: string[]; + description?: string; +} + +export interface UpdateGroupInput extends Partial { + status?: "ACTIVE" | "INACTIVE"; +} + +export interface GroupsSearchParams { + searchValue?: string; + propertiesToReturn?: string[]; + offset?: number; + limit?: number; +} + +export interface GroupResponse { + count: number; + total: number; + data: Group[]; + links: { + self: string; + next?: string; + }; +} + +export interface GroupMemberResponseItem { + group: { + id: string; + targetName: string; + recipientType: "GROUP"; + groupType: "ON_CALL" | "DYNAMIC" | "BROADCAST"; + links: { + self: string; + }; + }; + member: { + id: string; + targetName: string; + recipientType: "DEVICE" | "PERSON" | "GROUP"; + status: "ACTIVE" | "INACTIVE"; + deviceType?: string; + name?: string; + owner?: { + id: string; + targetName: string; + firstName: string; + lastName: string; + licenseType?: string; + recipientType: "PERSON"; + links: { + self: string; + }; + }; + groupType?: "ON_CALL" | "DYNAMIC" | "BROADCAST"; + links: { + self: string; + }; + }; +} + +export interface GroupMemberResponse { + count: number; + total: number; + data: GroupMemberResponseItem[]; + links: { + self: string; + next?: string; + }; +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..f6d8860 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,24 @@ +export class XmasError extends Error { + constructor(message: string) { + super(message); + this.name = 'XmasError'; + } +} + +export class XmasAuthError extends XmasError { + constructor(message: string) { + super(message); + this.name = 'XmasAuthError'; + } +} + +export class XmasApiError extends XmasError { + constructor( + message: string, + public readonly status: number, + public readonly response: unknown + ) { + super(message); + this.name = 'XmasApiError'; + } +} diff --git a/src/http/default_client.ts b/src/http/default_client.ts new file mode 100644 index 0000000..b7a18d6 --- /dev/null +++ b/src/http/default_client.ts @@ -0,0 +1,31 @@ +import type { HttpClient, HttpRequestParams } from "../types.ts"; +import { XmasApiError } from "../errors.ts"; + +export function createDefaultHttpClient(): HttpClient { + return { + sendRequest: async (params: HttpRequestParams) => { + const { method, url, headers, data } = params; + const response = await fetch(url, { + method, + headers, + body: data ? JSON.stringify(data) : undefined, + }); + const responseData = await response.json(); + if (!response.ok) { + throw new XmasApiError( + `Request failed with status ${response.status}`, + response.status, + responseData + ); + } + return responseData; + }, + successAdapter: (response: unknown) => response, + failureAdapter: (error: unknown) => { + if (error instanceof XmasApiError) { + throw error; + } + throw new XmasApiError("Request failed", 500, error); + }, + }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..a8c7f6a --- /dev/null +++ b/src/types.ts @@ -0,0 +1,29 @@ +export interface HttpClient { + sendRequest: (params: HttpRequestParams) => Promise; + successAdapter: (response: unknown) => unknown; + failureAdapter: (error: unknown) => never; +} + +export interface HttpRequestParams { + method: string; + url: string; + headers: Record; + data?: unknown; +} + +export interface XmasConfig { + hostname: string; + username?: string; + password?: string; + accessToken?: string; + refreshToken?: string; + clientId?: string; + httpClient?: HttpClient; +} + +export interface OAuthTokens { + accessToken: string; + refreshToken: string; +} + +export type ResourceConstructor = new (client: HttpClient, baseUrl: string) => T; diff --git a/src/xmas.ts b/src/xmas.ts new file mode 100644 index 0000000..05a62b4 --- /dev/null +++ b/src/xmas.ts @@ -0,0 +1,137 @@ +import type { XmasConfig, HttpClient, OAuthTokens, HttpRequestParams, ResourceConstructor } from "./types.ts"; +import { createDefaultHttpClient } from "./http/default_client.ts"; +import { XmasError } from "./errors.ts"; +import { Groups } from "./api/groups/index.ts"; + +export class Xmas { + private readonly httpClient: HttpClient; + private readonly baseUrl: string; + private readonly config: XmasConfig; + private accessToken?: string; + private refreshToken?: string; + private headers: Record; + + // Resource instances + private readonly resources = new Map, unknown>(); + + constructor(config: XmasConfig) { + this.validateConfig(config); + this.config = config; + this.baseUrl = config.hostname; + this.httpClient = config.httpClient ?? createDefaultHttpClient(); + + // Initialize auth tokens if provided + if (config.accessToken) { + this.accessToken = config.accessToken; + this.refreshToken = config.refreshToken; + } + + // Initialize base headers + this.headers = { + "Authorization": this.getAuthHeader(), + "Content-Type": "application/json", + "Accept": "application/json", + }; + } + + private validateConfig(config: XmasConfig): void { + if (!config.hostname) { + throw new XmasError('Configuration must include hostname'); + } + if (!config.username && !config.password && !config.accessToken) { + throw new XmasError('Configuration must include either username/password or accessToken'); + } + if ((config.username && !config.password) || (!config.username && config.password)) { + throw new XmasError('Both username and password must be provided together'); + } + } + + private getAuthHeader(): string { + if (this.accessToken) { + return `Bearer ${this.accessToken}`; + } + if (this.config.username && this.config.password) { + const encodedCredentials = btoa(`${this.config.username}:${this.config.password}`); + return `Basic ${encodedCredentials}`; + } + throw new XmasError("No valid authentication method available"); + } + + private async request( + method: string, + path: string, + data?: unknown, + useFullUrl = false + ): Promise { + const url = useFullUrl ? path : new URL(path, this.baseUrl).toString(); + const params: HttpRequestParams = { + method, + url, + headers: { ...this.headers }, + data, + }; + try { + const response = await this.httpClient.sendRequest(params); + return this.httpClient.successAdapter(response) as T; + } catch (error) { + throw this.httpClient.failureAdapter(error); + } + } + + async getOAuthTokens(): Promise { + if (!this.config.username || !this.config.password) { + throw new XmasError("Username and password are required for OAuth token exchange"); + } + const tokens = await this.request("POST", "/api/xm/1/oauth2/token", { + grant_type: "password", + username: this.config.username, + password: this.config.password, + client_id: this.config.clientId, + }); + this.accessToken = tokens.accessToken; + this.refreshToken = tokens.refreshToken; + // Update Authorization header with new token + this.headers.Authorization = this.getAuthHeader(); + return tokens; + } + + /** Send a request to a custom URL outside the standard API endpoints */ + sendRequest( + url: string, + options: { + method?: string; + data?: unknown; + headers?: Record; + } = {} + ): Promise { + const { method = "GET", data } = options; + return this.request(method, url, data, true); + } + + private wrapClient(client: HttpClient): HttpClient { + return { + ...client, + sendRequest: (params: HttpRequestParams) => + client.sendRequest({ + ...params, + headers: { ...this.headers, ...params.headers }, + }), + }; + } + + private getResource(ResourceClass: ResourceConstructor): T { + let resource = this.resources.get(ResourceClass) as T | undefined; + if (!resource) { + resource = new ResourceClass( + this.wrapClient(this.httpClient), + this.baseUrl, + ); + this.resources.set(ResourceClass, resource); + } + return resource; + } + + get groups(): Groups { + return this.getResource(Groups); + } +} From c7da65e5d31ce5edbbfd9ddd61e3818447da7850 Mon Sep 17 00:00:00 2001 From: Johan Date: Mon, 19 May 2025 18:48:16 -0700 Subject: [PATCH 003/101] deno Make the base get support various combinations of path + params --- src/api/base.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/api/base.ts b/src/api/base.ts index 09bbffc..feb1ce5 100644 --- a/src/api/base.ts +++ b/src/api/base.ts @@ -52,11 +52,15 @@ export abstract class BaseApi { } } - protected get(pathOrParams?: string | Record): Promise { - const [path, params] = typeof pathOrParams === 'string' - ? [pathOrParams, undefined] - : [undefined, pathOrParams as Record]; - return this.request("GET", path!, undefined, params); + protected get(): Promise; + protected get(params: Record): Promise; + protected get(path: string): Promise; + protected get(path: string, params: Record): Promise; + protected get(pathOrParams?: string | Record, params?: Record): Promise { + if (typeof pathOrParams === 'object') { + return this.request("GET", "", null, pathOrParams); + } + return this.request("GET", pathOrParams || "", null, params); } protected post(pathOrData?: string | unknown, data?: unknown): Promise { From 7abffc9eea159dbcc439977c39773af6efbb73b0 Mon Sep 17 00:00:00 2001 From: Johan Date: Sun, 1 Jun 2025 22:20:46 -0700 Subject: [PATCH 004/101] deno Rewrite core --- deno.json | 31 +----- deno.lock | 24 +---- mod.ts | 10 -- sandbox/index.ts | 39 ++++--- src/api/base.ts | 83 --------------- src/api/groups/index.ts | 104 ------------------ src/api/groups/types.ts | 99 ------------------ src/core/http.ts | 162 +++++++++++++++++++++++++++++ src/core/test-utils.ts | 99 ++++++++++++++++++ src/core/types.ts | 114 ++++++++++++++++++++ src/endpoints/groups/index.test.ts | 90 ++++++++++++++++ src/endpoints/groups/index.ts | 50 +++++++++ src/endpoints/groups/types.ts | 103 ++++++++++++++++++ src/errors.ts | 24 ----- src/http/default_client.ts | 31 ------ src/index.ts | 93 +++++++++++++++++ src/types.ts | 29 ------ src/xmas.ts | 137 ------------------------ 18 files changed, 745 insertions(+), 577 deletions(-) delete mode 100644 mod.ts delete mode 100644 src/api/base.ts delete mode 100644 src/api/groups/index.ts delete mode 100644 src/api/groups/types.ts create mode 100644 src/core/http.ts create mode 100644 src/core/test-utils.ts create mode 100644 src/core/types.ts create mode 100644 src/endpoints/groups/index.test.ts create mode 100644 src/endpoints/groups/index.ts create mode 100644 src/endpoints/groups/types.ts delete mode 100644 src/errors.ts delete mode 100644 src/http/default_client.ts create mode 100644 src/index.ts delete mode 100644 src/types.ts delete mode 100644 src/xmas.ts diff --git a/deno.json b/deno.json index 85712ea..93aae21 100644 --- a/deno.json +++ b/deno.json @@ -1,33 +1,8 @@ { - "name": "@jfx/xmas", - "version": "1.0.0", - "license": "MIT", - "exports": { - ".": "./mod.ts" - }, - "tasks": { - "test": "deno test", - "check": "deno check mod.ts" - }, - "publish": { - "exclude": ["tests/"], - "include": ["src/", "mod.ts", "README.md", "LICENSE"] - }, "imports": { - "@std/assert": "jsr:@std/assert@1" - }, - "compilerOptions": { - "strict": true, - "lib": ["deno.window", "deno.ns"] + "std/": "https://deno.land/std@0.193.0/" }, - "fmt": { - "files": { - "include": ["src/", "tests/", "mod.ts"] - }, - "options": { - "lineWidth": 100, - "indentWidth": 2, - "singleQuote": true - } + "tasks": { + "test": "deno test --allow-net" } } diff --git a/deno.lock b/deno.lock index 53c3964..ed6b6e5 100644 --- a/deno.lock +++ b/deno.lock @@ -1,23 +1,9 @@ { "version": "5", - "specifiers": { - "jsr:@std/assert@1": "1.0.7", - "jsr:@std/internal@^1.0.5": "1.0.5" - }, - "jsr": { - "@std/assert@1.0.7": { - "integrity": "64ce9fac879e0b9f3042a89b3c3f8ccfc9c984391af19e2087513a79d73e28c3", - "dependencies": [ - "jsr:@std/internal" - ] - }, - "@std/internal@1.0.5": { - "integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba" - } - }, - "workspace": { - "dependencies": [ - "jsr:@std/assert@1" - ] + "remote": { + "https://deno.land/std@0.193.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e", + "https://deno.land/std@0.193.0/testing/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", + "https://deno.land/std@0.193.0/testing/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", + "https://deno.land/std@0.193.0/testing/asserts.ts": "056d571baaefc7f13af3e29ad6a66d4dbe5355d3cb2ae130e7d2a1b1e01085e3" } } diff --git a/mod.ts b/mod.ts deleted file mode 100644 index 9f3df88..0000000 --- a/mod.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { Xmas } from "./src/xmas.ts"; -export type { XmasConfig } from "./src/types.ts"; -export * from "./src/errors.ts"; -export type { - Group, - CreateGroupInput, - UpdateGroupInput, - GroupsSearchParams, - GroupResponse, -} from "./src/api/groups/types.ts"; diff --git a/sandbox/index.ts b/sandbox/index.ts index 9f03a6e..f13a0ef 100644 --- a/sandbox/index.ts +++ b/sandbox/index.ts @@ -1,23 +1,36 @@ -import { Xmas } from '../mod.ts'; +import { XmApi } from '../src/index.ts'; import config from './credentials.ts'; -const xmas = new Xmas(config.basicAuth); +// Create API client using basic auth +const xm = new XmApi({ + hostname: config.basicAuth.hostname, + username: config.basicAuth.username, + password: config.basicAuth.password, +}); -xmas.groups - .find() - .then((res) => { - console.log(`Total Groups: ${res.total}`); - console.log(`Groups Count: ${res.count}`); +xm.groups + .getGroups({ + limit: 10, + offset: 0, + }) + .then((response) => { + console.log(`Total Groups: ${response.total}`); + console.log(`Groups Count: ${response.count}`); console.log('-------------------------'); - return res.data.forEach((group) => { + response.data.forEach((group) => { console.log(`Group ID: ${group.id}`); console.log(`Group Name: ${group.targetName}`); console.log(`Group Description: ${group.description}`); console.log('-------------------------'); - console.log(`RAWDOG: ${JSON.stringify(group, null, 2)}`); + console.log('Raw Response:', JSON.stringify(group, null, 2)); console.log('-------------------------'); }); - } -).catch((err) => { - console.error('Error fetching groups:', err); -}); + }) + .catch((error) => { + console.log('Error fetching groups:', error.message); + if (error.response) { + console.log('Response Status:', error.response.status); + console.log('Response Headers:', error.response.headers); + console.log('Response Body:', error.response.body); + } + }); diff --git a/src/api/base.ts b/src/api/base.ts deleted file mode 100644 index feb1ce5..0000000 --- a/src/api/base.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { HttpClient } from "../types.ts"; - -export abstract class BaseApi { - private static readonly API_VERSION = "/api/xm/1"; - - constructor( - private readonly client: HttpClient, - private readonly baseUrl: string, - private readonly resourcePath: string, - ) { - if (!resourcePath.startsWith("/")) { - throw new Error("Resource path must start with '/'"); - } - } - - private buildPath(pathOrParams?: string | Record): string { - // If it's search params or no path, return base resource path - if (!pathOrParams || typeof pathOrParams !== 'string') { - return `${BaseApi.API_VERSION}${this.resourcePath}`; - } - // Otherwise append the path segment - return `${BaseApi.API_VERSION}${this.resourcePath}/${pathOrParams}`; - } - - protected async request( - method: string, - path: string, - data?: unknown, - params?: Record - ): Promise { - const url = new URL(this.buildPath(path), this.baseUrl); - if (params) { - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined) { - url.searchParams.append(key, value); - } - }); - } - try { - const response = await this.client.sendRequest({ - method, - url: url.toString(), - headers: { - "Content-Type": "application/json", - "Accept": "application/json", - }, - data: data ? JSON.stringify(data) : undefined, - }); - return this.client.successAdapter(response) as T; - } catch (error) { - throw this.client.failureAdapter(error); - } - } - - protected get(): Promise; - protected get(params: Record): Promise; - protected get(path: string): Promise; - protected get(path: string, params: Record): Promise; - protected get(pathOrParams?: string | Record, params?: Record): Promise { - if (typeof pathOrParams === 'object') { - return this.request("GET", "", null, pathOrParams); - } - return this.request("GET", pathOrParams || "", null, params); - } - - protected post(pathOrData?: string | unknown, data?: unknown): Promise { - const [path, payload] = typeof pathOrData === 'string' - ? [pathOrData, data] - : ['', pathOrData]; - return this.request("POST", path, payload); - } - - protected put(pathOrData?: string | unknown, data?: unknown): Promise { - const [path, payload] = typeof pathOrData === 'string' - ? [pathOrData, data] - : ['', pathOrData]; - return this.request("PUT", path, payload); - } - - protected delete(path: string): Promise { - return this.request("DELETE", path); - } -} diff --git a/src/api/groups/index.ts b/src/api/groups/index.ts deleted file mode 100644 index e8fbc7f..0000000 --- a/src/api/groups/index.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { BaseApi } from "../base.ts"; -import type { HttpClient } from "../../types.ts"; -import type { - Group, - CreateGroupInput, - UpdateGroupInput, - GroupsSearchParams, - GroupResponse, - GroupMemberResponse, -} from "./types.ts"; - -export class Groups extends BaseApi { - constructor(client: HttpClient, baseUrl: string) { - super(client, baseUrl, "/groups"); - } - - /** - * Get all the members of a specific group - * @param groupId The ID of the group - * @returns A promise that resolves to the list of members in the group - * @example - * ```ts - * const members = await xm.groups.getMembers("dev-team"); - * console.log(members); - * ``` - * @throws {Error} If the request fails - */ - getMembers(groupId: string): Promise { - return this.get(`${groupId}/members`); - } - - /** - * Get a specific group by ID or targetName - * @param id The ID or targetName of the group - * @returns A promise that resolves to the group object - * @example - * ```ts - * const group = await xm.groups.getById("dev-team"); - * console.log(group); - * ``` - * @throws {Error} If the request fails - */ - getById(id: string): Promise { - return this.get(id); - } - - /** - * Search for groups using provided criteria - * @param params Optional search parameters including offset and limit for pagination - * @returns A promise that resolves to the group response object - * @example - * ```ts - * const response = await xm.groups.find({ searchValue: "Dev" }); - * console.log(response); - * ``` - * @throws {Error} If the request fails - */ - find(params?: GroupsSearchParams): Promise { - const searchParams: Record = {}; - if (params?.searchValue) searchParams.searchValue = params.searchValue; - if (params?.offset !== undefined) searchParams.offset = params.offset.toString(); - if (params?.limit !== undefined) searchParams.limit = params.limit.toString(); - if (params?.propertiesToReturn?.length) { - searchParams.propertiesToReturn = params.propertiesToReturn.join(","); - } - return this.get(searchParams); - } - - /** Create a new group */ - create(input: CreateGroupInput): Promise { - return this.post(input); - } - - /** - * Update an existing group - * @param id The group ID or targetName - */ - update(id: string, input: UpdateGroupInput): Promise { - return this.put(id, input); - } - - /** - * Delete a group - * @param id The group ID or targetName - */ - remove(id: string): Promise { - return this.delete(id); - } - - /** Add members to a group */ - addMembers(groupId: string, memberIds: string[]): Promise { - return this.post(`${groupId}/members`, { data: memberIds }); - } - - /** Remove members from a group */ - removeMembers(groupId: string, memberIds: string[]): Promise { - // xM API doesn't have a bulk delete, so we need to delete one at a time - return Promise.all( - memberIds.map(memberId => - this.delete(`${groupId}/members/${memberId}`) - ) - ).then(() => {}); - } -} diff --git a/src/api/groups/types.ts b/src/api/groups/types.ts deleted file mode 100644 index 86a7c74..0000000 --- a/src/api/groups/types.ts +++ /dev/null @@ -1,99 +0,0 @@ -export interface Group { - id: string; - targetName: string; - recipientType: "GROUP"; - status: "ACTIVE" | "INACTIVE"; - supervisors?: string[]; - observers?: string[]; - description?: string; - externallyOwned?: boolean; - allowDuplicates?: boolean; - useDefaultDevices?: boolean; - observedByAll?: boolean; - externalKey?: string; - site?: { - id: string; - name: string; - links: { - self: string; - }; - }; - links: { - self: string; - }; - created: string; - groupType: "ON_CALL" | "DYNAMIC" | "BROADCAST"; -} - -export interface CreateGroupInput { - targetName: string; - supervisors?: string[]; - observers?: string[]; - description?: string; -} - -export interface UpdateGroupInput extends Partial { - status?: "ACTIVE" | "INACTIVE"; -} - -export interface GroupsSearchParams { - searchValue?: string; - propertiesToReturn?: string[]; - offset?: number; - limit?: number; -} - -export interface GroupResponse { - count: number; - total: number; - data: Group[]; - links: { - self: string; - next?: string; - }; -} - -export interface GroupMemberResponseItem { - group: { - id: string; - targetName: string; - recipientType: "GROUP"; - groupType: "ON_CALL" | "DYNAMIC" | "BROADCAST"; - links: { - self: string; - }; - }; - member: { - id: string; - targetName: string; - recipientType: "DEVICE" | "PERSON" | "GROUP"; - status: "ACTIVE" | "INACTIVE"; - deviceType?: string; - name?: string; - owner?: { - id: string; - targetName: string; - firstName: string; - lastName: string; - licenseType?: string; - recipientType: "PERSON"; - links: { - self: string; - }; - }; - groupType?: "ON_CALL" | "DYNAMIC" | "BROADCAST"; - links: { - self: string; - }; - }; -} - -export interface GroupMemberResponse { - count: number; - total: number; - data: GroupMemberResponseItem[]; - links: { - self: string; - next?: string; - }; -} diff --git a/src/core/http.ts b/src/core/http.ts new file mode 100644 index 0000000..01eb2ed --- /dev/null +++ b/src/core/http.ts @@ -0,0 +1,162 @@ +import { + HttpClient, + HttpRequest, + HttpResponse, + Logger, + XmApiError, + GetOptions, + RequestWithBodyOptions, + DeleteOptions, +} from './types.ts'; + +export class DefaultHttpClient implements HttpClient { + async send(request: HttpRequest): Promise { + const response = await fetch(request.url, { + method: request.method, + headers: request.headers, + body: request.body ? JSON.stringify(request.body) : undefined, + }); + + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + + let body: unknown; + const contentType = headers['content-type']; + if (contentType?.includes('application/json')) { + body = await response.json(); + } else { + body = await response.text(); + } + + return { + status: response.status, + headers, + body, + }; + } +} + +export const defaultLogger: Logger = { + debug: console.debug.bind(console), + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), +}; + +export class RequestBuilder { + private readonly apiVersionPath = '/api/xm/1'; + + constructor( + private readonly baseUrl: string, + private readonly defaultHeaders: Record = {}, + ) {} + + build(request: Partial & { path: string }): HttpRequest { + const fullPath = `${this.apiVersionPath}${request.path}`; + const url = new URL(fullPath, this.baseUrl); + + // Add query parameters if present in the request + if (request.query) { + Object.entries(request.query).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)); + } + }); + } + + return { + method: request.method || 'GET', + path: request.path, + url: url.toString(), + query: request.query, + headers: { ...this.defaultHeaders, ...request.headers }, + body: request.body, + retryAttempt: request.retryAttempt || 0, + }; + } +} + +export class HttpHandler { + constructor( + private readonly client: HttpClient, + private readonly logger: Logger, + private readonly requestBuilder: RequestBuilder, + private readonly maxRetries: number = 3, + private readonly onTokenRefresh?: (accessToken: string, refreshToken: string) => void | Promise, + ) {} + + private async refreshToken(): Promise { + // TODO: Implement token refresh logic + return Promise.reject(new Error('Token refresh not implemented')); + } + + async send(request: Partial & { path: string; method?: HttpRequest['method'] }): Promise> { + const req = this.requestBuilder.build(request); + + try { + this.logger.debug('Sending request', { request: req }); + const response = await this.client.send(req); + this.logger.debug('Received response', { response }); + + if (response.status === 401 && this.onTokenRefresh && req.retryAttempt === 0) { + await this.refreshToken(); + return this.send({ ...request, retryAttempt: 1 }); + } + + if (response.status >= 400) { + throw new XmApiError( + `Request failed with status ${response.status}`, + { + status: response.status, + headers: response.headers, + body: typeof response.body === 'string' ? response.body : JSON.stringify(response.body), + }, + ); + } + + return response as HttpResponse; + } catch (error) { + if (error instanceof XmApiError) { + throw error; + } + + const attempt = req.retryAttempt || 0; + if (attempt < this.maxRetries) { + this.logger.warn('Request failed, retrying', { error, attempt }); + return this.send({ ...request, retryAttempt: attempt + 1 }); + } + + if (error instanceof Error) { + throw new XmApiError(`Request failed: ${error.message}`); + } + throw new XmApiError(`Request failed: ${String(error)}`); + } + } + + async get(options: GetOptions): Promise { + const response = await this.send(options); + return response.body; + } + + async post(options: RequestWithBodyOptions): Promise { + const response = await this.send({ ...options, method: 'POST' }); + return response.body; + } + + async put(options: RequestWithBodyOptions): Promise { + const response = await this.send({ ...options, method: 'PUT' }); + return response.body; + } + + async patch(options: RequestWithBodyOptions): Promise { + const response = await this.send({ ...options, method: 'PATCH' }); + return response.body; + } + + async delete(options: DeleteOptions): Promise { + const response = await this.send({ ...options, method: 'DELETE' }); + return response.body; + } +} diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts new file mode 100644 index 0000000..cc91fe5 --- /dev/null +++ b/src/core/test-utils.ts @@ -0,0 +1,99 @@ +import { HttpClient, HttpRequest, HttpResponse, Logger, XmApiError, XmApiOptions } from './types.ts'; +import { HttpHandler, RequestBuilder } from './http.ts'; + +class MockRequestBuilder extends RequestBuilder { + constructor() { + super('https://example.com'); + } + + override build(request: Partial & { path: string }): HttpRequest { + return { + method: request.method || 'GET', + path: request.path, + url: `https://example.com${request.path}`, + query: request.query, + headers: request.headers || {}, + body: request.body, + retryAttempt: request.retryAttempt || 0, + }; + } +} + +export class MockHttpHandler extends HttpHandler { + public readonly requests: HttpRequest[] = []; + private readonly responses: HttpResponse[]; + + constructor(responses: HttpResponse | HttpResponse[]) { + // Create minimal implementations for required dependencies + const mockClient: HttpClient = { + send: () => Promise.resolve({ status: 200, headers: {}, body: {} }) + }; + const mockLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {} + }; + const mockRequestBuilder = new MockRequestBuilder(); + + super(mockClient, mockLogger, mockRequestBuilder); + this.responses = Array.isArray(responses) ? responses : [responses]; + } + + override async send(request: Partial & { path: string; method?: HttpRequest['method'] }): Promise> { + const fullRequest: HttpRequest = { + method: request.method || 'GET', + path: request.path, + url: request.url || `https://example.com${request.path}`, + query: request.query, + headers: request.headers, + body: request.body, + retryAttempt: request.retryAttempt || 0, + }; + + this.requests.push(fullRequest); + + const response = this.responses[this.requests.length - 1] || this.responses[this.responses.length - 1]; + + // If status >= 400, throw an XmApiError + if (response.status >= 400) { + throw new XmApiError( + `Request failed with status ${response.status}`, + { + status: response.status, + headers: response.headers, + body: typeof response.body === 'string' ? response.body : JSON.stringify(response.body), + }, + ); + } + + return response as HttpResponse; + } +} + +export function createMockOptions(options: Partial = {}): XmApiOptions { + if ('accessToken' in options) { + return { + hostname: 'https://example.com', + accessToken: 'mock-token', + ...options, + }; + } + return { + hostname: 'https://example.com', + username: 'mock-user', + password: 'mock-password', + ...options, + }; +} + +export function createMockResponse(body: T, status = 200, headers: Record = {}): HttpResponse { + return { + body, + status, + headers: { + 'content-type': 'application/json', + ...headers, + }, + }; +} diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..7a5638e --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,114 @@ +/** + * Represents an HTTP response from the xMatters API. + * @template T The expected type of the response body + */ +export interface HttpResponse { + /** The parsed response body */ + body: T; + /** The HTTP status code */ + status: number; + /** Response headers */ + headers: Record; +} + +/** + * Represents an HTTP request to be sent to the xMatters API. + */ +export interface HttpRequest { + /** The HTTP method to use for the request */ + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + /** The complete URL for the request */ + url: string; + /** The path portion of the URL, used for testing and debugging */ + path: string; + /** Optional headers to send with the request */ + headers?: Record; + /** Optional query parameters to include in the URL */ + query?: Record; + /** Optional request body */ + body?: unknown; + /** Used internally for retry logic */ + retryAttempt?: number; +} + +export interface HttpClient { + send: (request: HttpRequest) => Promise; +} + +export interface Logger { + debug: (message: string, ...args: unknown[]) => void; + info: (message: string, ...args: unknown[]) => void; + warn: (message: string, ...args: unknown[]) => void; + error: (message: string, ...args: unknown[]) => void; +} + +export interface XmApiBaseOptions { + hostname: string; + httpClient?: HttpClient; + logger?: Logger; + defaultHeaders?: Record; + maxRetries?: number; +} + +export interface XmApiBasicAuthOptions extends XmApiBaseOptions { + username: string; + password: string; +} + +export interface XmApiOAuthOptions extends XmApiBaseOptions { + accessToken: string; + refreshToken?: string; + clientId?: string; + onTokenRefresh?: (accessToken: string, refreshToken: string) => void | Promise; +} + +export type XmApiOptions = XmApiBasicAuthOptions | XmApiOAuthOptions; + +export function isOAuthOptions(options: XmApiOptions): options is XmApiOAuthOptions { + return 'accessToken' in options; +} + +export class XmApiError extends Error { + constructor( + message: string, + public readonly response?: { + body: string; + status: number; + headers: Record; + } + ) { + super(message); + this.name = 'XmApiError'; + } +} + +/** + * Base interface for all HTTP method options + */ +export interface HttpMethodOptions { + /** The path portion of the URL, relative to the API version path */ + path: string; + /** Optional headers to send with the request */ + headers?: Record; +} + +/** + * Options for GET requests + */ +export interface GetOptions extends HttpMethodOptions { + /** Optional query parameters */ + query?: Record; +} + +/** + * Options for POST, PUT, and PATCH requests + */ +export interface RequestWithBodyOptions extends HttpMethodOptions { + /** The request body */ + body?: unknown; +} + +/** + * Options for DELETE requests + */ +export type DeleteOptions = HttpMethodOptions; diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts new file mode 100644 index 0000000..f532e04 --- /dev/null +++ b/src/endpoints/groups/index.test.ts @@ -0,0 +1,90 @@ +import { + assertEquals, + assertExists, + assertObjectMatch, +} from "https://deno.land/std@0.193.0/testing/asserts.ts"; +import { GroupsEndpoint } from "./index.ts"; +import { GetGroupsResponse } from "./types.ts"; +import { MockHttpHandler, createMockResponse } from "../../core/test-utils.ts"; + +const mockGroup = { + id: "123", + targetName: "Test Group", + recipientType: "GROUP" as const, + status: "ACTIVE" as const, + groupType: "ON_CALL" as const, + created: "2025-05-31T00:00:00Z", + description: "Test group description", + supervisors: ["user1"], + externallyOwned: false, + allowDuplicates: true, + useDefaultDevices: true, + observedByAll: true, + links: { + self: "/api/xm/1/groups/123" + }, +}; + +const mockGroupsResponse: GetGroupsResponse = { + count: 1, + total: 1, + data: [mockGroup], + links: { + self: "https://example.com/api/xm/1/groups", + }, +}; + +Deno.test("GroupsEndpoint", async (t) => { + await t.step("getGroups without parameters", async () => { + const mockHttp = new MockHttpHandler(createMockResponse(mockGroupsResponse)); + const endpoint = new GroupsEndpoint(mockHttp); + + const response = await endpoint.getGroups(); + + assertEquals(response, mockGroupsResponse); + assertEquals(mockHttp.requests.length, 1); + + const request = mockHttp.requests[0]; + assertEquals(request.method, "GET"); + assertEquals(request.path, "/groups"); + assertEquals(request.query, undefined); + }); + + await t.step("getGroups with parameters", async () => { + const mockHttp = new MockHttpHandler(createMockResponse(mockGroupsResponse)); + const endpoint = new GroupsEndpoint(mockHttp); + + const params = { limit: 10, offset: 0, search: "test" }; + const response = await endpoint.getGroups(params); + + assertEquals(response, mockGroupsResponse); + assertEquals(mockHttp.requests.length, 1); + + const request = mockHttp.requests[0]; + assertEquals(request.method, "GET"); + assertEquals(request.path, "/groups"); + assertExists(request.query); + assertObjectMatch(request.query, params); + }); + + await t.step("getGroups handles errors", async () => { + const errorResponse = createMockResponse({ message: "Not Found" }, 404); + const mockHttp = new MockHttpHandler(errorResponse); + const endpoint = new GroupsEndpoint(mockHttp); + + try { + await endpoint.getGroups(); + throw new Error("Expected error to be thrown"); + } catch (error) { + if (!(error instanceof Error)) { + throw new Error("Expected XmApiError but got: " + String(error)); + } + assertEquals(error.name, "XmApiError"); + assertEquals(error.message, "Request failed with status 404"); + // Type assertion since we know it's an XmApiError + const xmError = error as { response?: { status: number } }; + assertExists(xmError.response); + assertEquals(xmError.response.status, 404); + } + }); +}); diff --git a/src/endpoints/groups/index.ts b/src/endpoints/groups/index.ts new file mode 100644 index 0000000..77a7110 --- /dev/null +++ b/src/endpoints/groups/index.ts @@ -0,0 +1,50 @@ +import { HttpHandler } from '../../core/http.ts'; +import { GetGroupsParams, GetGroupsResponse } from './types.ts'; + +/** + * Provides access to the groups endpoints of the xMatters API. + * Use this class to manage groups, including listing, creating, updating, and deleting groups. + * + * @example + * ```typescript + * const xm = new XmApi({ + * baseUrl: 'https://example.xmatters.com', + * accessToken: 'your-token' + * }); + * + * // Get all groups + * const groups = await xm.groups.getGroups(); + * + * // Get groups with pagination + * const pagedGroups = await xm.groups.getGroups({ + * limit: 10, + * offset: 0 + * }); + * + * // Search for groups + * const searchedGroups = await xm.groups.getGroups({ + * search: 'oncall' + * }); + * ``` + */ +export class GroupsEndpoint { + /** Base path for all groups endpoints */ + private readonly basePath = '/groups'; + + constructor(private readonly http: HttpHandler) {} + + /** + * Get a list of groups from xMatters. + * The results can be filtered and paginated using the params object. + * + * @param options.params Optional parameters to filter and paginate the results + * @returns A paginated list of groups matching the filter criteria + * @throws {XmApiError} If the request fails + */ + getGroups(params?: GetGroupsParams): Promise { + return this.http.get({ + path: this.basePath, + query: params ? { ...params } : undefined + }); + } +} diff --git a/src/endpoints/groups/types.ts b/src/endpoints/groups/types.ts new file mode 100644 index 0000000..5868dc7 --- /dev/null +++ b/src/endpoints/groups/types.ts @@ -0,0 +1,103 @@ +/** + * Represents a group in xMatters. + */ +export interface Group { + /** Unique identifier for the group */ + id: string; + + /** The name of the group used for targeting */ + targetName: string; + + /** Type of recipient */ + recipientType: 'GROUP' | 'DEVICE' | 'PERSON'; + + /** Whether the group is active or inactive */ + status: 'ACTIVE' | 'INACTIVE'; + + /** The type of group */ + groupType: 'ON_CALL' | 'BROADCAST' | 'DYNAMIC'; + + /** ISO timestamp when the group was created */ + created: string; + + /** Optional description of the group's purpose */ + description?: string; + + /** List of user IDs that are supervisors of this group */ + supervisors?: string[]; + + /** Whether the group is managed by an external system */ + externallyOwned?: boolean; + + /** Whether duplicate members are allowed */ + allowDuplicates?: boolean; + + /** Whether to use default devices for members */ + useDefaultDevices?: boolean; + + /** Whether the group is visible to all users */ + observedByAll?: boolean; + + /** External identifier for the group */ + externalKey?: string; + + /** Site information if the group belongs to a specific site */ + site?: { + id: string; + name: string; + links: { + self: string; + }; + }; + + /** HAL links for the group */ + links?: { + /** URL to this group resource */ + self: string; + }; + + /** ISO timestamp when the group was last modified */ + lastModified?: string; +} + +/** + * Parameters for the getGroups endpoint. + * Used to filter and paginate the list of groups. + */ +export interface GetGroupsParams { + /** + * The maximum number of records to return. + * @default 100 + */ + limit?: number; + + /** + * The number of records to skip. + * Used for pagination in combination with limit. + * @default 0 + */ + offset?: number; + + /** + * A string used to filter records by matching on all or part of a group name. + * The search is case-insensitive and matches any part of the group name. + */ + search?: string; + + /** + * Filter records by matching on the exact value of targetName. + * This is case-sensitive and must match the group name exactly. + */ + targetName?: string; +} + +export interface GetGroupsResponse { + count: number; + total: number; + data: Group[]; + links?: { + self: string; + next?: string; + prev?: string; + }; +} diff --git a/src/errors.ts b/src/errors.ts deleted file mode 100644 index f6d8860..0000000 --- a/src/errors.ts +++ /dev/null @@ -1,24 +0,0 @@ -export class XmasError extends Error { - constructor(message: string) { - super(message); - this.name = 'XmasError'; - } -} - -export class XmasAuthError extends XmasError { - constructor(message: string) { - super(message); - this.name = 'XmasAuthError'; - } -} - -export class XmasApiError extends XmasError { - constructor( - message: string, - public readonly status: number, - public readonly response: unknown - ) { - super(message); - this.name = 'XmasApiError'; - } -} diff --git a/src/http/default_client.ts b/src/http/default_client.ts deleted file mode 100644 index b7a18d6..0000000 --- a/src/http/default_client.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { HttpClient, HttpRequestParams } from "../types.ts"; -import { XmasApiError } from "../errors.ts"; - -export function createDefaultHttpClient(): HttpClient { - return { - sendRequest: async (params: HttpRequestParams) => { - const { method, url, headers, data } = params; - const response = await fetch(url, { - method, - headers, - body: data ? JSON.stringify(data) : undefined, - }); - const responseData = await response.json(); - if (!response.ok) { - throw new XmasApiError( - `Request failed with status ${response.status}`, - response.status, - responseData - ); - } - return responseData; - }, - successAdapter: (response: unknown) => response, - failureAdapter: (error: unknown) => { - if (error instanceof XmasApiError) { - throw error; - } - throw new XmasApiError("Request failed", 500, error); - }, - }; -} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ae63efd --- /dev/null +++ b/src/index.ts @@ -0,0 +1,93 @@ +import { DefaultHttpClient, HttpHandler, RequestBuilder, defaultLogger } from './core/http.ts'; +import { XmApiOptions } from './core/types.ts'; +import { GroupsEndpoint } from './endpoints/groups/index.ts'; + +/** + * Main entry point for the xMatters API client. + * This class provides access to all API endpoints through its properties. + * + * @example Basic Authentication + * ```typescript + * const xm = new XmApi({ + * hostname: 'https://example.xmatters.com', + * username: 'your-username', + * password: 'your-password', + * // Optional configurations + * httpClient: myCustomHttpClient, + * logger: myCustomLogger, + * defaultHeaders: { 'Custom-Header': 'value' }, + * maxRetries: 3, + * }); + * ``` + * + * @example OAuth Authentication + * ```typescript + * const xm = new XmApi({ + * hostname: 'https://example.xmatters.com', + * accessToken: 'your-token', + * refreshToken: 'your-refresh-token', + * // Optional configurations + * httpClient: myCustomHttpClient, + * logger: myCustomLogger, + * defaultHeaders: { 'Custom-Header': 'value' }, + * maxRetries: 3, + * onTokenRefresh: (accessToken, refreshToken) => { + * // Store the new tokens + * }, + * }); + * ``` + */ +export class XmApi { + /** HTTP handler that manages all API requests */ + private readonly http: HttpHandler; + + /** Access groups-related endpoints */ + public readonly groups: GroupsEndpoint; + + /** + * Creates the authorization header value based on the authentication type + */ + private createAuthorizationHeader(options: XmApiOptions): string { + if ('accessToken' in options) { + return `Bearer ${options.accessToken}`; + } else { + // In Deno, we use TextEncoder for proper UTF-8 encoding + const encoder = new TextEncoder(); + const authString = `${options.username}:${options.password}`; + const auth = btoa(String.fromCharCode(...encoder.encode(authString))); + return `Basic ${auth}`; + } + } + + constructor(options: XmApiOptions) { + const { + hostname, + httpClient = new DefaultHttpClient(), + logger = defaultLogger, + defaultHeaders = {}, + maxRetries = 3, + } = options; + + // Set up default headers with auth + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...defaultHeaders, + 'Authorization': this.createAuthorizationHeader(options), + }; + + const requestBuilder = new RequestBuilder(hostname, headers); + + // Get onTokenRefresh callback if using OAuth + const onTokenRefresh = 'onTokenRefresh' in options ? options.onTokenRefresh : undefined; + + this.http = new HttpHandler(httpClient, logger, requestBuilder, maxRetries, onTokenRefresh); + + // Initialize endpoints + this.groups = new GroupsEndpoint(this.http); + } +} + +// Re-export types +export * from './core/types.ts'; +export * from './endpoints/groups/types.ts'; \ No newline at end of file diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index a8c7f6a..0000000 --- a/src/types.ts +++ /dev/null @@ -1,29 +0,0 @@ -export interface HttpClient { - sendRequest: (params: HttpRequestParams) => Promise; - successAdapter: (response: unknown) => unknown; - failureAdapter: (error: unknown) => never; -} - -export interface HttpRequestParams { - method: string; - url: string; - headers: Record; - data?: unknown; -} - -export interface XmasConfig { - hostname: string; - username?: string; - password?: string; - accessToken?: string; - refreshToken?: string; - clientId?: string; - httpClient?: HttpClient; -} - -export interface OAuthTokens { - accessToken: string; - refreshToken: string; -} - -export type ResourceConstructor = new (client: HttpClient, baseUrl: string) => T; diff --git a/src/xmas.ts b/src/xmas.ts deleted file mode 100644 index 05a62b4..0000000 --- a/src/xmas.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type { XmasConfig, HttpClient, OAuthTokens, HttpRequestParams, ResourceConstructor } from "./types.ts"; -import { createDefaultHttpClient } from "./http/default_client.ts"; -import { XmasError } from "./errors.ts"; -import { Groups } from "./api/groups/index.ts"; - -export class Xmas { - private readonly httpClient: HttpClient; - private readonly baseUrl: string; - private readonly config: XmasConfig; - private accessToken?: string; - private refreshToken?: string; - private headers: Record; - - // Resource instances - private readonly resources = new Map, unknown>(); - - constructor(config: XmasConfig) { - this.validateConfig(config); - this.config = config; - this.baseUrl = config.hostname; - this.httpClient = config.httpClient ?? createDefaultHttpClient(); - - // Initialize auth tokens if provided - if (config.accessToken) { - this.accessToken = config.accessToken; - this.refreshToken = config.refreshToken; - } - - // Initialize base headers - this.headers = { - "Authorization": this.getAuthHeader(), - "Content-Type": "application/json", - "Accept": "application/json", - }; - } - - private validateConfig(config: XmasConfig): void { - if (!config.hostname) { - throw new XmasError('Configuration must include hostname'); - } - if (!config.username && !config.password && !config.accessToken) { - throw new XmasError('Configuration must include either username/password or accessToken'); - } - if ((config.username && !config.password) || (!config.username && config.password)) { - throw new XmasError('Both username and password must be provided together'); - } - } - - private getAuthHeader(): string { - if (this.accessToken) { - return `Bearer ${this.accessToken}`; - } - if (this.config.username && this.config.password) { - const encodedCredentials = btoa(`${this.config.username}:${this.config.password}`); - return `Basic ${encodedCredentials}`; - } - throw new XmasError("No valid authentication method available"); - } - - private async request( - method: string, - path: string, - data?: unknown, - useFullUrl = false - ): Promise { - const url = useFullUrl ? path : new URL(path, this.baseUrl).toString(); - const params: HttpRequestParams = { - method, - url, - headers: { ...this.headers }, - data, - }; - try { - const response = await this.httpClient.sendRequest(params); - return this.httpClient.successAdapter(response) as T; - } catch (error) { - throw this.httpClient.failureAdapter(error); - } - } - - async getOAuthTokens(): Promise { - if (!this.config.username || !this.config.password) { - throw new XmasError("Username and password are required for OAuth token exchange"); - } - const tokens = await this.request("POST", "/api/xm/1/oauth2/token", { - grant_type: "password", - username: this.config.username, - password: this.config.password, - client_id: this.config.clientId, - }); - this.accessToken = tokens.accessToken; - this.refreshToken = tokens.refreshToken; - // Update Authorization header with new token - this.headers.Authorization = this.getAuthHeader(); - return tokens; - } - - /** Send a request to a custom URL outside the standard API endpoints */ - sendRequest( - url: string, - options: { - method?: string; - data?: unknown; - headers?: Record; - } = {} - ): Promise { - const { method = "GET", data } = options; - return this.request(method, url, data, true); - } - - private wrapClient(client: HttpClient): HttpClient { - return { - ...client, - sendRequest: (params: HttpRequestParams) => - client.sendRequest({ - ...params, - headers: { ...this.headers, ...params.headers }, - }), - }; - } - - private getResource(ResourceClass: ResourceConstructor): T { - let resource = this.resources.get(ResourceClass) as T | undefined; - if (!resource) { - resource = new ResourceClass( - this.wrapClient(this.httpClient), - this.baseUrl, - ); - this.resources.set(ResourceClass, resource); - } - return resource; - } - - get groups(): Groups { - return this.getResource(Groups); - } -} From a6fbb83721b36b5262fafa83add880da02646bd8 Mon Sep 17 00:00:00 2001 From: Johan Date: Sun, 1 Jun 2025 22:45:00 -0700 Subject: [PATCH 005/101] Refactor test-util and add auto format on save --- .vscode/settings.json | 16 ++++++ deno.json | 6 +- src/core/http.ts | 26 ++++++--- src/core/test-utils.ts | 92 +++++++++++++++++++++--------- src/core/types.ts | 2 +- src/endpoints/groups/index.test.ts | 82 +++++++++++++++----------- src/endpoints/groups/index.ts | 22 +++---- src/endpoints/groups/types.ts | 34 +++++------ src/index.ts | 16 +++--- 9 files changed, 189 insertions(+), 107 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a6baba --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,16 @@ +{ + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno", + "editor.formatOnSave": true + }, + "[json]": { + "editor.defaultFormatter": "denoland.vscode-deno", + "editor.formatOnSave": true + }, + "[jsonc]": { + "editor.defaultFormatter": "denoland.vscode-deno", + "editor.formatOnSave": true + }, + "deno.enable": true, + "deno.lint": true +} diff --git a/deno.json b/deno.json index 93aae21..7fe8959 100644 --- a/deno.json +++ b/deno.json @@ -3,6 +3,10 @@ "std/": "https://deno.land/std@0.193.0/" }, "tasks": { - "test": "deno test --allow-net" + "test": "deno test" + }, + "fmt": { + "singleQuote": true, + "lineWidth": 100 } } diff --git a/src/core/http.ts b/src/core/http.ts index 01eb2ed..abb77c2 100644 --- a/src/core/http.ts +++ b/src/core/http.ts @@ -1,12 +1,12 @@ import { + DeleteOptions, + GetOptions, HttpClient, HttpRequest, HttpResponse, Logger, - XmApiError, - GetOptions, RequestWithBodyOptions, - DeleteOptions, + XmApiError, } from './types.ts'; export class DefaultHttpClient implements HttpClient { @@ -56,7 +56,6 @@ export class RequestBuilder { build(request: Partial & { path: string }): HttpRequest { const fullPath = `${this.apiVersionPath}${request.path}`; const url = new URL(fullPath, this.baseUrl); - // Add query parameters if present in the request if (request.query) { Object.entries(request.query).forEach(([key, value]) => { @@ -65,7 +64,6 @@ export class RequestBuilder { } }); } - return { method: request.method || 'GET', path: request.path, @@ -84,7 +82,10 @@ export class HttpHandler { private readonly logger: Logger, private readonly requestBuilder: RequestBuilder, private readonly maxRetries: number = 3, - private readonly onTokenRefresh?: (accessToken: string, refreshToken: string) => void | Promise, + private readonly onTokenRefresh?: ( + accessToken: string, + refreshToken: string, + ) => void | Promise, ) {} private async refreshToken(): Promise { @@ -92,15 +93,22 @@ export class HttpHandler { return Promise.reject(new Error('Token refresh not implemented')); } - async send(request: Partial & { path: string; method?: HttpRequest['method'] }): Promise> { + async send( + request: Partial & { + path: string; + method?: HttpRequest['method']; + }, + ): Promise> { const req = this.requestBuilder.build(request); - + try { this.logger.debug('Sending request', { request: req }); const response = await this.client.send(req); this.logger.debug('Received response', { response }); - if (response.status === 401 && this.onTokenRefresh && req.retryAttempt === 0) { + if ( + response.status === 401 && this.onTokenRefresh && req.retryAttempt === 0 + ) { await this.refreshToken(); return this.send({ ...request, retryAttempt: 1 }); } diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts index cc91fe5..b00f9fd 100644 --- a/src/core/test-utils.ts +++ b/src/core/test-utils.ts @@ -1,4 +1,11 @@ -import { HttpClient, HttpRequest, HttpResponse, Logger, XmApiError, XmApiOptions } from './types.ts'; +import { + HttpClient, + HttpRequest, + HttpResponse, + Logger, + XmApiError, + XmApiOptions, +} from './types.ts'; import { HttpHandler, RequestBuilder } from './http.ts'; class MockRequestBuilder extends RequestBuilder { @@ -26,21 +33,24 @@ export class MockHttpHandler extends HttpHandler { constructor(responses: HttpResponse | HttpResponse[]) { // Create minimal implementations for required dependencies const mockClient: HttpClient = { - send: () => Promise.resolve({ status: 200, headers: {}, body: {} }) + send: () => Promise.resolve({ status: 200, headers: {}, body: {} }), }; const mockLogger: Logger = { debug: () => {}, info: () => {}, warn: () => {}, - error: () => {} + error: () => {}, }; const mockRequestBuilder = new MockRequestBuilder(); - + super(mockClient, mockLogger, mockRequestBuilder); this.responses = Array.isArray(responses) ? responses : [responses]; } - override async send(request: Partial & { path: string; method?: HttpRequest['method'] }): Promise> { + // deno-lint-ignore require-await + override async send( + request: Partial & { path: string; method?: HttpRequest['method'] }, + ): Promise> { const fullRequest: HttpRequest = { method: request.method || 'GET', path: request.path, @@ -52,9 +62,10 @@ export class MockHttpHandler extends HttpHandler { }; this.requests.push(fullRequest); - - const response = this.responses[this.requests.length - 1] || this.responses[this.responses.length - 1]; - + + const response = this.responses[this.requests.length - 1] || + this.responses[this.responses.length - 1]; + // If status >= 400, throw an XmApiError if (response.status >= 400) { throw new XmApiError( @@ -66,34 +77,61 @@ export class MockHttpHandler extends HttpHandler { }, ); } - + return response as HttpResponse; } } -export function createMockOptions(options: Partial = {}): XmApiOptions { - if ('accessToken' in options) { - return { - hostname: 'https://example.com', - accessToken: 'mock-token', - ...options, - }; - } - return { - hostname: 'https://example.com', - username: 'mock-user', - password: 'mock-password', - ...options, - }; +export interface CreateMockResponseOptions { + /** The response body */ + body: T; + /** The HTTP status code */ + status?: number; + /** Response headers */ + headers?: Record; } -export function createMockResponse(body: T, status = 200, headers: Record = {}): HttpResponse { +export function createMockResponse(options: CreateMockResponseOptions): HttpResponse { return { - body, - status, + body: options.body, + status: options.status ?? 200, headers: { 'content-type': 'application/json', - ...headers, + ...options.headers, }, }; } + +export interface CreateMockOptionsConfig { + /** Optional base URL for the mock API. Defaults to https://example.com */ + mockBaseUrl?: string; + /** OAuth configuration */ + oauth?: { + accessToken?: string; + refreshToken?: string; + clientId?: string; + }; + /** Basic Auth configuration */ + basicAuth?: { + username?: string; + password?: string; + }; +} + +export function createMockOptions(config: CreateMockOptionsConfig = {}): XmApiOptions { + const { mockBaseUrl = 'https://example.com' } = config; + + if (config.oauth) { + return { + hostname: mockBaseUrl, + accessToken: config.oauth.accessToken ?? 'mock-token', + refreshToken: config.oauth.refreshToken, + clientId: config.oauth.clientId, + }; + } + return { + hostname: mockBaseUrl, + username: config.basicAuth?.username ?? 'mock-user', + password: config.basicAuth?.password ?? 'mock-password', + }; +} diff --git a/src/core/types.ts b/src/core/types.ts index 7a5638e..38ee292 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -75,7 +75,7 @@ export class XmApiError extends Error { body: string; status: number; headers: Record; - } + }, ) { super(message); this.name = 'XmApiError'; diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index f532e04..90542a3 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -2,26 +2,26 @@ import { assertEquals, assertExists, assertObjectMatch, -} from "https://deno.land/std@0.193.0/testing/asserts.ts"; -import { GroupsEndpoint } from "./index.ts"; -import { GetGroupsResponse } from "./types.ts"; -import { MockHttpHandler, createMockResponse } from "../../core/test-utils.ts"; +} from 'https://deno.land/std@0.193.0/testing/asserts.ts'; +import { GroupsEndpoint } from './index.ts'; +import { GetGroupsResponse } from './types.ts'; +import { createMockResponse, MockHttpHandler } from '../../core/test-utils.ts'; const mockGroup = { - id: "123", - targetName: "Test Group", - recipientType: "GROUP" as const, - status: "ACTIVE" as const, - groupType: "ON_CALL" as const, - created: "2025-05-31T00:00:00Z", - description: "Test group description", - supervisors: ["user1"], + id: '123', + targetName: 'Test Group', + recipientType: 'GROUP' as const, + status: 'ACTIVE' as const, + groupType: 'ON_CALL' as const, + created: '2025-05-31T00:00:00Z', + description: 'Test group description', + supervisors: ['user1'], externallyOwned: false, allowDuplicates: true, useDefaultDevices: true, observedByAll: true, links: { - self: "/api/xm/1/groups/123" + self: '/api/xm/1/groups/123', }, }; @@ -30,57 +30,73 @@ const mockGroupsResponse: GetGroupsResponse = { total: 1, data: [mockGroup], links: { - self: "https://example.com/api/xm/1/groups", + self: 'https://example.com/api/xm/1/groups', }, }; -Deno.test("GroupsEndpoint", async (t) => { - await t.step("getGroups without parameters", async () => { - const mockHttp = new MockHttpHandler(createMockResponse(mockGroupsResponse)); +Deno.test('GroupsEndpoint', async (t) => { + await t.step('getGroups without parameters', async () => { + const mockHttp = new MockHttpHandler(createMockResponse({ + body: mockGroupsResponse, + headers: { + 'content-type': 'application/json', + }, + })); const endpoint = new GroupsEndpoint(mockHttp); const response = await endpoint.getGroups(); assertEquals(response, mockGroupsResponse); assertEquals(mockHttp.requests.length, 1); - + const request = mockHttp.requests[0]; - assertEquals(request.method, "GET"); - assertEquals(request.path, "/groups"); + assertEquals(request.method, 'GET'); + assertEquals(request.path, '/groups'); assertEquals(request.query, undefined); }); - await t.step("getGroups with parameters", async () => { - const mockHttp = new MockHttpHandler(createMockResponse(mockGroupsResponse)); + await t.step('getGroups with parameters', async () => { + const mockHttp = new MockHttpHandler(createMockResponse({ + body: mockGroupsResponse, + headers: { + 'content-type': 'application/json', + }, + })); const endpoint = new GroupsEndpoint(mockHttp); - - const params = { limit: 10, offset: 0, search: "test" }; + + const params = { limit: 10, offset: 0, search: 'test' }; const response = await endpoint.getGroups(params); assertEquals(response, mockGroupsResponse); assertEquals(mockHttp.requests.length, 1); - + const request = mockHttp.requests[0]; - assertEquals(request.method, "GET"); - assertEquals(request.path, "/groups"); + assertEquals(request.method, 'GET'); + assertEquals(request.path, '/groups'); assertExists(request.query); assertObjectMatch(request.query, params); }); - await t.step("getGroups handles errors", async () => { - const errorResponse = createMockResponse({ message: "Not Found" }, 404); + await t.step('getGroups handles errors', async () => { + const errorResponse = createMockResponse({ + body: { message: 'Not Found' }, + status: 404, + headers: { + 'content-type': 'application/json', + }, + }); const mockHttp = new MockHttpHandler(errorResponse); const endpoint = new GroupsEndpoint(mockHttp); try { await endpoint.getGroups(); - throw new Error("Expected error to be thrown"); + throw new Error('Expected error to be thrown'); } catch (error) { if (!(error instanceof Error)) { - throw new Error("Expected XmApiError but got: " + String(error)); + throw new Error('Expected XmApiError but got: ' + String(error)); } - assertEquals(error.name, "XmApiError"); - assertEquals(error.message, "Request failed with status 404"); + assertEquals(error.name, 'XmApiError'); + assertEquals(error.message, 'Request failed with status 404'); // Type assertion since we know it's an XmApiError const xmError = error as { response?: { status: number } }; assertExists(xmError.response); diff --git a/src/endpoints/groups/index.ts b/src/endpoints/groups/index.ts index 77a7110..1bfa19e 100644 --- a/src/endpoints/groups/index.ts +++ b/src/endpoints/groups/index.ts @@ -4,26 +4,26 @@ import { GetGroupsParams, GetGroupsResponse } from './types.ts'; /** * Provides access to the groups endpoints of the xMatters API. * Use this class to manage groups, including listing, creating, updating, and deleting groups. - * + * * @example * ```typescript * const xm = new XmApi({ * baseUrl: 'https://example.xmatters.com', * accessToken: 'your-token' * }); - * + * * // Get all groups * const groups = await xm.groups.getGroups(); - * + * * // Get groups with pagination - * const pagedGroups = await xm.groups.getGroups({ - * limit: 10, - * offset: 0 + * const pagedGroups = await xm.groups.getGroups({ + * limit: 10, + * offset: 0 * }); - * + * * // Search for groups - * const searchedGroups = await xm.groups.getGroups({ - * search: 'oncall' + * const searchedGroups = await xm.groups.getGroups({ + * search: 'oncall' * }); * ``` */ @@ -36,7 +36,7 @@ export class GroupsEndpoint { /** * Get a list of groups from xMatters. * The results can be filtered and paginated using the params object. - * + * * @param options.params Optional parameters to filter and paginate the results * @returns A paginated list of groups matching the filter criteria * @throws {XmApiError} If the request fails @@ -44,7 +44,7 @@ export class GroupsEndpoint { getGroups(params?: GetGroupsParams): Promise { return this.http.get({ path: this.basePath, - query: params ? { ...params } : undefined + query: params ? { ...params } : undefined, }); } } diff --git a/src/endpoints/groups/types.ts b/src/endpoints/groups/types.ts index 5868dc7..138d97d 100644 --- a/src/endpoints/groups/types.ts +++ b/src/endpoints/groups/types.ts @@ -4,43 +4,43 @@ export interface Group { /** Unique identifier for the group */ id: string; - + /** The name of the group used for targeting */ targetName: string; - + /** Type of recipient */ recipientType: 'GROUP' | 'DEVICE' | 'PERSON'; - + /** Whether the group is active or inactive */ status: 'ACTIVE' | 'INACTIVE'; - + /** The type of group */ groupType: 'ON_CALL' | 'BROADCAST' | 'DYNAMIC'; - + /** ISO timestamp when the group was created */ created: string; - + /** Optional description of the group's purpose */ description?: string; - + /** List of user IDs that are supervisors of this group */ supervisors?: string[]; /** Whether the group is managed by an external system */ externallyOwned?: boolean; - + /** Whether duplicate members are allowed */ allowDuplicates?: boolean; - + /** Whether to use default devices for members */ useDefaultDevices?: boolean; - + /** Whether the group is visible to all users */ observedByAll?: boolean; - + /** External identifier for the group */ externalKey?: string; - + /** Site information if the group belongs to a specific site */ site?: { id: string; @@ -49,13 +49,13 @@ export interface Group { self: string; }; }; - + /** HAL links for the group */ links?: { /** URL to this group resource */ self: string; }; - + /** ISO timestamp when the group was last modified */ lastModified?: string; } @@ -70,20 +70,20 @@ export interface GetGroupsParams { * @default 100 */ limit?: number; - + /** * The number of records to skip. * Used for pagination in combination with limit. * @default 0 */ offset?: number; - + /** * A string used to filter records by matching on all or part of a group name. * The search is case-insensitive and matches any part of the group name. */ search?: string; - + /** * Filter records by matching on the exact value of targetName. * This is case-sensitive and must match the group name exactly. diff --git a/src/index.ts b/src/index.ts index ae63efd..dccaadf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,11 @@ -import { DefaultHttpClient, HttpHandler, RequestBuilder, defaultLogger } from './core/http.ts'; +import { DefaultHttpClient, defaultLogger, HttpHandler, RequestBuilder } from './core/http.ts'; import { XmApiOptions } from './core/types.ts'; import { GroupsEndpoint } from './endpoints/groups/index.ts'; /** * Main entry point for the xMatters API client. * This class provides access to all API endpoints through its properties. - * + * * @example Basic Authentication * ```typescript * const xm = new XmApi({ @@ -19,7 +19,7 @@ import { GroupsEndpoint } from './endpoints/groups/index.ts'; * maxRetries: 3, * }); * ``` - * + * * @example OAuth Authentication * ```typescript * const xm = new XmApi({ @@ -40,7 +40,7 @@ import { GroupsEndpoint } from './endpoints/groups/index.ts'; export class XmApi { /** HTTP handler that manages all API requests */ private readonly http: HttpHandler; - + /** Access groups-related endpoints */ public readonly groups: GroupsEndpoint; @@ -77,12 +77,12 @@ export class XmApi { }; const requestBuilder = new RequestBuilder(hostname, headers); - + // Get onTokenRefresh callback if using OAuth const onTokenRefresh = 'onTokenRefresh' in options ? options.onTokenRefresh : undefined; - + this.http = new HttpHandler(httpClient, logger, requestBuilder, maxRetries, onTokenRefresh); - + // Initialize endpoints this.groups = new GroupsEndpoint(this.http); } @@ -90,4 +90,4 @@ export class XmApi { // Re-export types export * from './core/types.ts'; -export * from './endpoints/groups/types.ts'; \ No newline at end of file +export * from './endpoints/groups/types.ts'; From 5014001cafa50d1b40d148500ffae1b5eddfd0d2 Mon Sep 17 00:00:00 2001 From: Johan Date: Sun, 1 Jun 2025 23:07:51 -0700 Subject: [PATCH 006/101] Rethink types for reusability --- src/core/types.ts | 150 ++++++++++++++++++++++++++++++++++ src/endpoints/groups/types.ts | 46 ++++------- 2 files changed, 164 insertions(+), 32 deletions(-) diff --git a/src/core/types.ts b/src/core/types.ts index 38ee292..2098b65 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -112,3 +112,153 @@ export interface RequestWithBodyOptions extends HttpMethodOptions { * Options for DELETE requests */ export type DeleteOptions = HttpMethodOptions; + +/** + * Common pagination parameters used across many endpoints + */ +export interface PaginationParams { + /** + * The maximum number of records to return + * @default 100 + */ + limit?: number; + + /** + * The number of records to skip + * Used for pagination in combination with limit + * @default 0 + */ + offset?: number; +} + +/** + * Common search parameters used across many endpoints + */ +export interface SearchParams { + /** + * A string used to filter records by matching on names or other searchable fields + * The search is typically case-insensitive and matches any part of the searchable fields + */ + search?: string; +} + +/** + * Common sorting parameters used across many endpoints + */ +export interface SortParams { + /** + * Field to sort by + */ + sortBy?: string; + + /** + * Sort direction + * @default 'ASC' + */ + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * Helper type to add pagination to endpoint parameters + */ +export type WithPagination = Record> = + & T + & PaginationParams; + +/** + * Helper type to add search capability to endpoint parameters + */ +export type WithSearch = Record> = + & T + & SearchParams; + +/** + * Helper type to add sorting to endpoint parameters + */ +export type WithSort = Record> = T & SortParams; + +/** + * Common response wrapper for paginated lists + */ +export interface PaginatedResponse { + /** Number of items in this response */ + count: number; + /** Total number of items available */ + total: number; + /** The items for this page */ + data: T[]; + /** HAL links for navigation */ + links?: { + /** URL to current page */ + self: string; + /** URL to next page, if available */ + next?: string; + /** URL to previous page, if available */ + prev?: string; + }; +} + +/** + * Common type utilities for building endpoint parameter types. + * + * @example Simple paginated endpoint + * ```typescript + * interface DeviceFilters extends Record { + * status?: 'ACTIVE' | 'INACTIVE'; + * } + * + * type GetDevicesParams = WithPagination; + * // Results in: + * // { + * // status?: 'ACTIVE' | 'INACTIVE'; + * // limit?: number; + * // offset?: number; + * // } + * ``` + * + * @example Endpoint with search and pagination + * ```typescript + * interface UserFilters extends Record { + * role?: string; + * } + * + * // Compose multiple parameter types + * type GetUsersParams = WithPagination>; + * // Results in: + * // { + * // role?: string; + * // search?: string; + * // limit?: number; + * // offset?: number; + * // } + * ``` + * + * @example Full endpoint type definition + * ```typescript + * // 1. Define your resource type + * interface User { + * id: string; + * name: string; + * // ...other properties + * } + * + * // 2. Define endpoint-specific filters + * interface UserFilters extends Record { + * role?: string; + * status?: 'ACTIVE' | 'INACTIVE'; + * } + * + * // 3. Compose parameter types with pagination, search, and sort + * type GetUsersParams = WithPagination>>; + * + * // 4. Use the generic paginated response + * type GetUsersResponse = PaginatedResponse; + * + * // Now you can implement your endpoint: + * class UsersEndpoint { + * async getUsers(params?: GetUsersParams): Promise { + * return this.http.get({ path: '/users', query: params }); + * } + * } + * ``` + */ diff --git a/src/endpoints/groups/types.ts b/src/endpoints/groups/types.ts index 138d97d..9508b7e 100644 --- a/src/endpoints/groups/types.ts +++ b/src/endpoints/groups/types.ts @@ -1,3 +1,5 @@ +import { PaginatedResponse, WithPagination, WithSearch } from '../../core/types.ts'; + /** * Represents a group in xMatters. */ @@ -61,29 +63,9 @@ export interface Group { } /** - * Parameters for the getGroups endpoint. - * Used to filter and paginate the list of groups. + * Specific filter parameters for the getGroups endpoint */ -export interface GetGroupsParams { - /** - * The maximum number of records to return. - * @default 100 - */ - limit?: number; - - /** - * The number of records to skip. - * Used for pagination in combination with limit. - * @default 0 - */ - offset?: number; - - /** - * A string used to filter records by matching on all or part of a group name. - * The search is case-insensitive and matches any part of the group name. - */ - search?: string; - +export interface GroupFilters extends Record { /** * Filter records by matching on the exact value of targetName. * This is case-sensitive and must match the group name exactly. @@ -91,13 +73,13 @@ export interface GetGroupsParams { targetName?: string; } -export interface GetGroupsResponse { - count: number; - total: number; - data: Group[]; - links?: { - self: string; - next?: string; - prev?: string; - }; -} +/** + * Parameters for the getGroups endpoint. + * Combines common pagination and search with group-specific filters. + */ +export type GetGroupsParams = WithPagination>; + +/** + * Response type for the getGroups endpoint. + */ +export type GetGroupsResponse = PaginatedResponse; From ed33286761b356311d34d965dcb826b1c660e620 Mon Sep 17 00:00:00 2001 From: Johan Date: Thu, 5 Jun 2025 00:11:52 -0700 Subject: [PATCH 007/101] Refine the patterns Bulletproof the http client Add more unit test where it matters Document things and ideas for future improvements --- docs/improvements.md | 137 +++++++++++++ sandbox/index.ts | 9 +- src/core/endpoint-http.ts | 77 ++++++++ src/core/http.test.ts | 197 +++++++++++++++++++ src/core/http.ts | 303 ++++++++++++++++++++++++----- src/core/oauth-types.ts | 39 ++++ src/core/test-utils.ts | 111 ++++++++--- src/core/types.ts | 21 ++ src/endpoints/groups/index.test.ts | 139 ++++++++++++- src/endpoints/groups/index.ts | 61 ++++-- 10 files changed, 990 insertions(+), 104 deletions(-) create mode 100644 docs/improvements.md create mode 100644 src/core/endpoint-http.ts create mode 100644 src/core/http.test.ts create mode 100644 src/core/oauth-types.ts diff --git a/docs/improvements.md b/docs/improvements.md new file mode 100644 index 0000000..f36802b --- /dev/null +++ b/docs/improvements.md @@ -0,0 +1,137 @@ +# Potential Improvements + +## HTTP Handler Retry Strategy + +Currently, the HttpHandler implements a built-in retry strategy. Here are the potential improvements +to consider with detailed implementation notes. + +### Option 1: Keep Built-in Strategy but Make it More Configurable + +Implementation details: + +1. Add a RetryConfig interface to `src/core/types.ts`: + +```typescript +export interface RetryConfig { + /** Maximum number of retry attempts (default: 3) */ + maxRetries?: number; + /** Base delay in ms for exponential backoff (default: 1000) */ + baseDelay?: number; + /** Maximum delay in ms (default: 10000) */ + maxDelay?: number; + /** Which status codes should trigger a retry (default: [429, 500-599]) */ + retryableStatuses?: number[]; + /** Custom function to determine if a response should be retried */ + shouldRetry?: (response: HttpResponse, attempt: number) => boolean | Promise; + /** Custom function to calculate delay between retries */ + getDelay?: (response: HttpResponse, attempt: number) => number | Promise; +} +``` + +2. Update HttpHandler constructor to accept this config: + +```typescript +constructor( + client: HttpClient, + logger: Logger, + requestBuilder: RequestBuilder, + retryConfig?: RetryConfig, + onTokenRefresh?: (accessToken: string, refreshToken: string) => void | Promise, + tokenData?: TokenData, +) +``` + +3. Implement custom retry logic in send() method using these options + +### Option 2: Move Retry Logic to Adapters + +Implementation details: + +1. Add RetryingHttpClient class to `src/core/http-client.ts`: + +```typescript +export class RetryingHttpClient implements HttpClient { + constructor( + innerClient: HttpClient, + config: RetryConfig, + logger: Logger, + ); +} +``` + +2. Move all retry-related code from HttpHandler to this class +3. Update DefaultHttpClient to optionally wrap itself with RetryingHttpClient +4. Add examples in README showing how to implement custom retry strategies + +### Option 3: Hybrid Approach + +Implementation details: + +1. Keep basic retry logic in HttpHandler but only for token refresh +2. Allow wrapping any HttpClient with retry behavior: + +```typescript +const client = new DefaultHttpClient(); +const retryingClient = new RetryingHttpClient(client, { + maxRetries: 3, + shouldRetry: (res) => res.status === 429 +}); +const handler = new HttpHandler(retryingClient, ...); +``` + +3. Provide common retry strategies as utilities: + +```typescript +export const retryStrategies = { + exponentialBackoff: (baseDelay: number, maxDelay: number) => {...}, + fixedDelay: (delay: number) => {...}, + httpStatusBased: (statusCodes: number[]) => {...} +}; +``` + +## Current Recommendation + +Keep the current implementation but create a new branch implementing Option 1. This provides: + +- Better configurability for power users +- Same great defaults for simple use cases +- No breaking changes +- Clear path to Option 3 if needed later + +The minimal first step would be to extract retry configuration while keeping current behavior as the +default: + +```typescript +// Default config matching current behavior +const DEFAULT_RETRY_CONFIG: Required = { + maxRetries: 3, + baseDelay: 1000, + maxDelay: 10000, + retryableStatuses: [429, ...Array.from({ length: 100 }, (_, i) => 500 + i)], + shouldRetry: (res) => res.status === 429 || res.status >= 500, + getDelay: (res, attempt) => { + if (res.status === 429 && res.headers['retry-after']) { + const retryAfter = parseInt(res.headers['retry-after'], 10); + if (!isNaN(retryAfter)) return retryAfter * 1000; + } + return Math.min(1000 * Math.pow(2, attempt), 10000); + }, +}; +``` + +## Testing Considerations + +Any chosen implementation must: + +1. Be fully testable without real network calls +2. Have predictable timing in tests (mock setTimeout) +3. Allow verification of retry attempts +4. Support testing of custom retry strategies +5. Maintain current test coverage levels + +## Migration Strategy + +1. Create new interfaces/configs without breaking changes +2. Add new functionality behind feature flags or as opt-in +3. Update documentation with examples +4. Consider creating utilities to help migrate between approaches diff --git a/sandbox/index.ts b/sandbox/index.ts index f13a0ef..2ab8f39 100644 --- a/sandbox/index.ts +++ b/sandbox/index.ts @@ -14,10 +14,13 @@ xm.groups offset: 0, }) .then((response) => { - console.log(`Total Groups: ${response.total}`); - console.log(`Groups Count: ${response.count}`); + const { body, status, headers } = response; + console.log('Response Status:', status); + console.log('Response Headers:', headers); + console.log(`Total Groups: ${body.total}`); + console.log(`Groups Count: ${body.count}`); console.log('-------------------------'); - response.data.forEach((group) => { + body.data.forEach((group) => { console.log(`Group ID: ${group.id}`); console.log(`Group Name: ${group.targetName}`); console.log(`Group Description: ${group.description}`); diff --git a/src/core/endpoint-http.ts b/src/core/endpoint-http.ts new file mode 100644 index 0000000..475cc95 --- /dev/null +++ b/src/core/endpoint-http.ts @@ -0,0 +1,77 @@ +import type { DeleteOptions, GetOptions, HttpRequest, RequestWithBodyOptions } from './types.ts'; +import { HttpHandler } from './http.ts'; + +/** + * A wrapper around HttpHandler that automatically prepends a base path to all requests. + * This allows endpoint classes to focus on their specific paths without repeating the base path. + */ +export class EndpointHttpHandler { + constructor( + private readonly http: HttpHandler, + private readonly basePath: string, + ) { + if (!basePath.startsWith('/')) { + throw new Error('Base path must start with a /'); + } + } + + /** + * Prepends the base path to the given path + */ + private buildPath(path?: string): string { + if (!path) { + return this.basePath; + } + // Strip any leading slash from the path since we'll add it + const cleanPath = path.startsWith('/') ? path.slice(1) : path; + return `${this.basePath}/${cleanPath}`; + } + + /** + * Send a request with custom options. + * This is a low-level method that bypasses the automatic base path handling. + * Use this when you need complete control over the request path. + * + * @returns The full HTTP response + */ + send( + request: Partial & { path: string; method: HttpRequest['method'] }, + ) { + return this.http.send(request); + } + + get(options: Omit & { path?: string }) { + return this.http.get({ + ...options, + path: this.buildPath(options.path), + }); + } + + post(options: Omit & { path?: string }) { + return this.http.post({ + ...options, + path: this.buildPath(options.path), + }); + } + + put(options: Omit & { path?: string }) { + return this.http.put({ + ...options, + path: this.buildPath(options.path), + }); + } + + patch(options: Omit & { path?: string }) { + return this.http.patch({ + ...options, + path: this.buildPath(options.path), + }); + } + + delete(options: Omit & { path?: string }) { + return this.http.delete({ + ...options, + path: this.buildPath(options.path), + }); + } +} diff --git a/src/core/http.test.ts b/src/core/http.test.ts new file mode 100644 index 0000000..dcfa2bb --- /dev/null +++ b/src/core/http.test.ts @@ -0,0 +1,197 @@ +import { + assertEquals, + assertExists, + assertRejects, +} from 'https://deno.land/std@0.193.0/testing/asserts.ts'; +import { HttpHandler, RequestBuilder } from './http.ts'; +import { HttpClient, HttpRequest, HttpResponse, Logger, XmApiError } from './types.ts'; + +class TestHttpClient implements HttpClient { + public requests: HttpRequest[] = []; + public responses: HttpResponse[] = []; + public forceError?: Error; + + async send(request: HttpRequest): Promise { + this.requests.push(request); + if (this.forceError) { + throw this.forceError; + } + return this.responses[this.requests.length - 1] || this.responses[this.responses.length - 1]; + } +} + +const mockLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; + +Deno.test('HttpHandler', async (t) => { + await t.step('handles non-JSON response bodies', async () => { + const client = new TestHttpClient(); + const requestBuilder = new RequestBuilder('https://example.com'); + const handler = new HttpHandler(client, mockLogger, requestBuilder); + + client.responses = [{ + status: 400, + headers: { 'content-type': 'text/plain' }, + body: 'Invalid request', + }]; + + await assertRejects( + async () => await handler.get({ path: '/test' }), + XmApiError, + 'Invalid request', + ); + }); + + await t.step('retries on rate limit with Retry-After', async () => { + const client = new TestHttpClient(); + const requestBuilder = new RequestBuilder('https://example.com'); + const handler = new HttpHandler(client, mockLogger, requestBuilder); + + client.responses = [ + { + status: 429, + headers: { 'retry-after': '1' }, + body: { message: 'Too many requests' }, + }, + { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { success: true }, + }, + ]; + + const response = await handler.get({ path: '/test' }); + assertEquals(response.status, 200); + assertEquals(client.requests.length, 2); + assertEquals(client.requests[1].retryAttempt, 1); + }); + + await t.step('retries with exponential backoff on server error', async () => { + const client = new TestHttpClient(); + const requestBuilder = new RequestBuilder('https://example.com'); + const handler = new HttpHandler(client, mockLogger, requestBuilder); + + client.responses = [ + { + status: 503, + headers: {}, + body: { message: 'Service unavailable' }, + }, + { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { success: true }, + }, + ]; + + const response = await handler.get({ path: '/test' }); + assertEquals(response.status, 200); + assertEquals(client.requests.length, 2); + assertEquals(client.requests[1].retryAttempt, 1); + }); + + await t.step('stops retrying after max attempts', async () => { + const client = new TestHttpClient(); + const requestBuilder = new RequestBuilder('https://example.com'); + const handler = new HttpHandler(client, mockLogger, requestBuilder); + + client.responses = Array(5).fill({ + status: 503, + headers: {}, + body: { message: 'Service unavailable' }, + }); + + await assertRejects( + async () => await handler.get({ path: '/test' }), + XmApiError, + 'Service unavailable', + ); + + assertEquals(client.requests.length, 4); // Initial + 3 retries + }); + + await t.step('handles network errors', async () => { + const client = new TestHttpClient(); + const requestBuilder = new RequestBuilder('https://example.com'); + const handler = new HttpHandler(client, mockLogger, requestBuilder); + + client.forceError = new Error('Network error'); + + try { + await handler.get({ path: '/test' }); + throw new Error('Expected error to be thrown'); + } catch (error: unknown) { + assertExists(error); + assertEquals(error instanceof XmApiError, true); + const xmError = error as XmApiError; + assertEquals(xmError.message, 'Request failed'); + assertEquals(xmError.response, undefined); + assertEquals(xmError.cause instanceof Error, true); + assertEquals((xmError.cause as Error).message, 'Network error'); + } + }); + + await t.step('refreshes token on 401 response', async () => { + const client = new TestHttpClient(); + const requestBuilder = new RequestBuilder('https://example.com'); + const handler = new HttpHandler( + client, + mockLogger, + requestBuilder, + 3, + undefined, + { + accessToken: 'old-token', + refreshToken: 'refresh-token', + clientId: 'client-id', + }, + ); + + client.responses = [ + // Initial request fails with 401 + { + status: 401, + headers: {}, + body: { message: 'Token expired' }, + }, + // Token refresh succeeds + { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { + access_token: 'new-token', + refresh_token: 'new-refresh-token', + expires_in: 3600, + }, + }, + // Original request succeeds with new token + { + status: 200, + headers: {}, + body: { success: true }, + }, + ]; + + const response = await handler.get({ path: '/test' }); + assertEquals(response.status, 200); + assertEquals(client.requests.length, 3); + + // Verify token refresh request + const refreshRequest = client.requests[1]; + assertEquals(refreshRequest.path, '/oauth2/token'); + assertEquals(refreshRequest.headers?.['Content-Type'], 'application/x-www-form-urlencoded'); + assertExists(refreshRequest.body); + const params = new URLSearchParams(refreshRequest.body as string); + assertEquals(params.get('grant_type'), 'refresh_token'); + assertEquals(params.get('refresh_token'), 'refresh-token'); + assertEquals(params.get('client_id'), 'client-id'); + + // Verify retried request uses new token + const retriedRequest = client.requests[2]; + assertEquals(retriedRequest.headers?.Authorization, 'Bearer new-token'); + }); +}); diff --git a/src/core/http.ts b/src/core/http.ts index abb77c2..c5e1c94 100644 --- a/src/core/http.ts +++ b/src/core/http.ts @@ -8,13 +8,28 @@ import { RequestWithBodyOptions, XmApiError, } from './types.ts'; +import { TokenData, TokenState } from './oauth-types.ts'; export class DefaultHttpClient implements HttpClient { async send(request: HttpRequest): Promise { + // Handle body serialization based on content type + let serializedBody: string | undefined; + const contentType = request.headers?.['content-type']; + if (request.body) { + if (contentType?.includes('application/json')) { + serializedBody = JSON.stringify(request.body); + } else if (typeof request.body === 'string') { + serializedBody = request.body; + } else { + // Default to JSON if no content type specified + serializedBody = JSON.stringify(request.body); + } + } + const response = await fetch(request.url, { method: request.method, headers: request.headers, - body: request.body ? JSON.stringify(request.body) : undefined, + body: serializedBody, }); const headers: Record = {}; @@ -23,9 +38,14 @@ export class DefaultHttpClient implements HttpClient { }); let body: unknown; - const contentType = headers['content-type']; - if (contentType?.includes('application/json')) { - body = await response.json(); + const responseType = headers['content-type']; + if (responseType?.includes('application/json')) { + try { + body = await response.json(); + } catch (e) { + // If JSON parsing fails, fall back to text + body = await response.text(); + } } else { body = await response.text(); } @@ -64,7 +84,8 @@ export class RequestBuilder { } }); } - return { + + const builtRequest: HttpRequest = { method: request.method || 'GET', path: request.path, url: url.toString(), @@ -73,10 +94,47 @@ export class RequestBuilder { body: request.body, retryAttempt: request.retryAttempt || 0, }; + + return builtRequest; } } export class HttpHandler { + /** Current token state if using OAuth */ + private tokenState?: TokenState; + + /** + * Helper method to safely convert a response body to a string for error messages + */ + private stringifyErrorBody(body: unknown): string { + if (typeof body === 'string') { + return body; + } + if (body && typeof body === 'object') { + try { + return JSON.stringify(body); + } catch { + return '[Unable to stringify error body]'; + } + } + return String(body ?? '[No error details available]'); + } + + /** + * Helper method to create an error response + */ + private createErrorResponse(response: HttpResponse): { + body: string; + status: number; + headers: Record; + } { + return { + body: this.stringifyErrorBody(response.body), + status: response.status, + headers: response.headers, + }; + } + constructor( private readonly client: HttpClient, private readonly logger: Logger, @@ -86,11 +144,110 @@ export class HttpHandler { accessToken: string, refreshToken: string, ) => void | Promise, - ) {} + tokenData?: TokenData, + ) { + // If we have token data, initialize token state + if (tokenData) { + this.tokenState = { + ...tokenData, + // Set a default expiry 5 minutes from now - we'll get the real value on first refresh + expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), + scopes: [], + clientId: tokenData.clientId, + }; + } + } + + private isTokenExpired(): boolean { + if (!this.tokenState) return false; + const expiresAt = new Date(this.tokenState.expiresAt); + // Consider token expired if it expires in less than 30 seconds + return expiresAt.getTime() - Date.now() <= 30 * 1000; + } private async refreshToken(): Promise { - // TODO: Implement token refresh logic - return Promise.reject(new Error('Token refresh not implemented')); + try { + if (!this.tokenState?.refreshToken) { + throw new XmApiError('No refresh token available for token refresh'); + } + + const params = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: this.tokenState.refreshToken, + }); + + // Add client ID if available (required for some OAuth2 servers) + if (this.tokenState.clientId) { + params.append('client_id', this.tokenState.clientId); + } + + const refreshRequest = this.requestBuilder.build({ + method: 'POST', + path: '/oauth2/token', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }, + body: params.toString(), + }); + + const response = await this.client.send(refreshRequest); + + if (response.status !== 200 || !response.body) { + throw new XmApiError('Failed to refresh token', this.createErrorResponse(response)); + } + + if (typeof response.body !== 'object' || !response.body) { + throw new XmApiError( + 'Invalid token response format', + this.createErrorResponse(response), + ); + } + + const body = response.body as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + scope?: string; + }; + + if (!body.access_token || !body.refresh_token) { + throw new XmApiError( + 'Token response missing required fields', + this.createErrorResponse(response), + ); + } + + this.tokenState = { + ...this.tokenState, // Preserve clientId and other fields + accessToken: body.access_token, + refreshToken: body.refresh_token, + scopes: body.scope?.split(' ') ?? [], + expiresAt: new Date(Date.now() + ((body.expires_in ?? 3600) * 1000)).toISOString(), + }; + + if (this.onTokenRefresh) { + try { + await this.onTokenRefresh( + this.tokenState.accessToken, + this.tokenState.refreshToken, + ); + } catch (error) { + this.logger.warn( + 'Error in onTokenRefresh callback, but continuing with refreshed token', + error, + ); + } + } + } catch (error) { + this.logger.error('Failed to refresh token:', error); + throw error; + } + } + + private exponentialBackoff(attempt: number): number { + // Calculate delay with exponential backoff: 1s, 2s, 4s, 8s, capped at 10s + return Math.min(1000 * Math.pow(2, attempt), 10000); } async send( @@ -99,28 +256,87 @@ export class HttpHandler { method?: HttpRequest['method']; }, ): Promise> { - const req = this.requestBuilder.build(request); + // Check if token refresh is needed before making the request + if (this.tokenState && this.isTokenExpired()) { + await this.refreshToken(); + } + + const fullRequest = this.requestBuilder.build(request); + + // Add authorization header if we have a token + if (this.tokenState?.accessToken) { + fullRequest.headers = { + ...fullRequest.headers, + Authorization: `Bearer ${this.tokenState.accessToken}`, + }; + } try { - this.logger.debug('Sending request', { request: req }); - const response = await this.client.send(req); - this.logger.debug('Received response', { response }); - - if ( - response.status === 401 && this.onTokenRefresh && req.retryAttempt === 0 - ) { - await this.refreshToken(); - return this.send({ ...request, retryAttempt: 1 }); - } + const response = await this.client.send(fullRequest); if (response.status >= 400) { + const currentAttempt = fullRequest.retryAttempt ?? 0; + + // Handle OAuth token expiry/refresh first + if ( + response.status === 401 && + this.tokenState?.refreshToken && + currentAttempt === 0 + ) { + await this.refreshToken(); + // Retry the original request with new token + return this.send({ + ...request, + retryAttempt: 1, + }); + } + + // For rate limits (429) or server errors (5xx), retry with exponential backoff + if ( + (response.status === 429 || response.status >= 500) && + currentAttempt < this.maxRetries + ) { + // Calculate delay based on retry attempt + const delay = this.exponentialBackoff(currentAttempt); + + // Respect Retry-After header for rate limits if present + let finalDelay = delay; + if (response.status === 429 && response.headers['retry-after']) { + const retryAfter = parseInt(response.headers['retry-after'], 10); + if (!isNaN(retryAfter)) { + finalDelay = retryAfter * 1000; + } + } + + this.logger.debug( + `Request failed with status ${response.status}, retrying in ${finalDelay}ms (attempt ${ + currentAttempt + 1 + }/${this.maxRetries})`, + ); + + await new Promise((resolve) => setTimeout(resolve, finalDelay)); + return this.send({ + ...request, + retryAttempt: currentAttempt + 1, + }); + } + + // Try to extract a descriptive message from the response + let message = `Request failed with status ${response.status}`; + if (response.body && typeof response.body === 'object' && 'message' in response.body) { + message = String(response.body.message); + } else if (typeof response.body === 'string' && response.body.trim()) { + message = response.body.trim(); + } + + // Add error code if available + if (response.body && typeof response.body === 'object' && 'code' in response.body) { + message = `${response.body.code}: ${message}`; + } + throw new XmApiError( - `Request failed with status ${response.status}`, - { - status: response.status, - headers: response.headers, - body: typeof response.body === 'string' ? response.body : JSON.stringify(response.body), - }, + message, + this.createErrorResponse(response), ); } @@ -129,42 +345,27 @@ export class HttpHandler { if (error instanceof XmApiError) { throw error; } - - const attempt = req.retryAttempt || 0; - if (attempt < this.maxRetries) { - this.logger.warn('Request failed, retrying', { error, attempt }); - return this.send({ ...request, retryAttempt: attempt + 1 }); - } - - if (error instanceof Error) { - throw new XmApiError(`Request failed: ${error.message}`); - } - throw new XmApiError(`Request failed: ${String(error)}`); + throw new XmApiError('Request failed', undefined, error); } } - async get(options: GetOptions): Promise { - const response = await this.send(options); - return response.body; + get(options: GetOptions): Promise> { + return this.send(options); } - async post(options: RequestWithBodyOptions): Promise { - const response = await this.send({ ...options, method: 'POST' }); - return response.body; + post(options: RequestWithBodyOptions): Promise> { + return this.send({ ...options, method: 'POST' }); } - async put(options: RequestWithBodyOptions): Promise { - const response = await this.send({ ...options, method: 'PUT' }); - return response.body; + put(options: RequestWithBodyOptions): Promise> { + return this.send({ ...options, method: 'PUT' }); } - async patch(options: RequestWithBodyOptions): Promise { - const response = await this.send({ ...options, method: 'PATCH' }); - return response.body; + patch(options: RequestWithBodyOptions): Promise> { + return this.send({ ...options, method: 'PATCH' }); } - async delete(options: DeleteOptions): Promise { - const response = await this.send({ ...options, method: 'DELETE' }); - return response.body; + delete(options: DeleteOptions): Promise> { + return this.send({ ...options, method: 'DELETE' }); } } diff --git a/src/core/oauth-types.ts b/src/core/oauth-types.ts new file mode 100644 index 0000000..4ba0d86 --- /dev/null +++ b/src/core/oauth-types.ts @@ -0,0 +1,39 @@ +/** + * Response from the OAuth2 token endpoint. + */ +export interface OAuth2TokenResponse { + /** The access token to use for authenticated requests */ + access_token: string; + /** Token to use to get a new access token when it expires */ + refresh_token: string; + /** How many seconds until the access token expires */ + expires_in: number; + /** The type of token, typically 'Bearer' */ + token_type: 'Bearer' | string; + /** The scopes granted to the token (space-separated string) */ + scope?: string; +} + +/** + * Data structure for managing OAuth2 tokens with metadata and helper methods. + */ +export interface TokenState extends TokenData { + /** ISO timestamp when the access token expires */ + expiresAt: string; + /** Scopes granted to the token */ + scopes: string[]; + /** Optional client ID used for token refresh */ + clientId?: string; +} + +/** + * Basic token data required for OAuth2 authentication. + */ +export interface TokenData { + /** Token to use for authenticating requests */ + accessToken: string; + /** Token to use for getting a new access token */ + refreshToken: string; + /** Optional client ID used for OAuth2 server authentication */ + clientId?: string; +} diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts index b00f9fd..9f3437a 100644 --- a/src/core/test-utils.ts +++ b/src/core/test-utils.ts @@ -29,11 +29,12 @@ class MockRequestBuilder extends RequestBuilder { export class MockHttpHandler extends HttpHandler { public readonly requests: HttpRequest[] = []; private readonly responses: HttpResponse[]; + public forceError?: Error; constructor(responses: HttpResponse | HttpResponse[]) { // Create minimal implementations for required dependencies const mockClient: HttpClient = { - send: () => Promise.resolve({ status: 200, headers: {}, body: {} }), + send: async () => ({ status: 200, headers: {}, body: {} }), }; const mockLogger: Logger = { debug: () => {}, @@ -47,38 +48,92 @@ export class MockHttpHandler extends HttpHandler { this.responses = Array.isArray(responses) ? responses : [responses]; } - // deno-lint-ignore require-await override async send( request: Partial & { path: string; method?: HttpRequest['method'] }, ): Promise> { - const fullRequest: HttpRequest = { - method: request.method || 'GET', - path: request.path, - url: request.url || `https://example.com${request.path}`, - query: request.query, - headers: request.headers, - body: request.body, - retryAttempt: request.retryAttempt || 0, - }; + try { + const fullRequest: HttpRequest = { + method: request.method || 'GET', + path: request.path, + url: request.url || `https://example.com${request.path}`, + query: request.query, + headers: request.headers, + body: request.body, + retryAttempt: request.retryAttempt || 0, + }; - this.requests.push(fullRequest); - - const response = this.responses[this.requests.length - 1] || - this.responses[this.responses.length - 1]; - - // If status >= 400, throw an XmApiError - if (response.status >= 400) { - throw new XmApiError( - `Request failed with status ${response.status}`, - { - status: response.status, - headers: response.headers, - body: typeof response.body === 'string' ? response.body : JSON.stringify(response.body), - }, - ); - } + this.requests.push(fullRequest); + + // If forceError is set, throw it as an XmApiError + if (this.forceError) { + throw new XmApiError('Request failed', undefined, this.forceError); + } - return response as HttpResponse; + const currentAttempt = fullRequest.retryAttempt ?? 0; + const currentResponse = this.responses[currentAttempt]; + if (!currentResponse) { + throw new XmApiError('No mock response available for request'); + } + + // For retryable responses (429 or 5xx), check if we have a next response + if ( + (currentResponse.status === 429 || + (currentResponse.status >= 500 && currentResponse.status < 600)) && + this.responses[currentAttempt + 1] + ) { + // For rate limits, respect the Retry-After header + if (currentResponse.status === 429 && currentResponse.headers['retry-after']) { + const retryAfter = parseInt(currentResponse.headers['retry-after'], 10); + if (!isNaN(retryAfter)) { + await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000)); + } + } + + // Retry the request with the next response + return this.send({ + ...request, + retryAttempt: currentAttempt + 1, + }); + } + + // For non-retryable errors or if we're out of responses, throw an error + if (currentResponse.status >= 400) { + let message = `Request failed with status ${currentResponse.status}`; + if ( + currentResponse.body && typeof currentResponse.body === 'object' && + 'message' in currentResponse.body + ) { + message = String(currentResponse.body.message); + } else if (typeof currentResponse.body === 'string' && currentResponse.body.trim()) { + message = currentResponse.body.trim(); + } + + if ( + currentResponse.body && typeof currentResponse.body === 'object' && + 'code' in currentResponse.body + ) { + message = `${currentResponse.body.code}: ${message}`; + } + + throw new XmApiError( + message, + { + status: currentResponse.status, + headers: currentResponse.headers, + body: typeof currentResponse.body === 'string' + ? currentResponse.body + : JSON.stringify(currentResponse.body), + }, + ); + } + + return currentResponse as HttpResponse; + } catch (error) { + if (error instanceof XmApiError) { + throw error; + } + throw new XmApiError('Request failed', undefined, error); + } } } diff --git a/src/core/types.ts b/src/core/types.ts index 2098b65..f293698 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -68,17 +68,38 @@ export function isOAuthOptions(options: XmApiOptions): options is XmApiOAuthOpti return 'accessToken' in options; } +/** + * Base class for all errors thrown by the xMatters API client. + * Contains information about the failed request and response. + */ export class XmApiError extends Error { + /** + * @param message Human-readable error message + * @param response Optional response details if the error occurred after receiving a response + * @param cause Optional underlying error that caused this error + */ constructor( message: string, public readonly response?: { + /** The response body as a string */ body: string; + /** The HTTP status code that triggered this error */ status: number; + /** Response headers that may contain additional error context */ headers: Record; }, + public override readonly cause?: unknown, ) { super(message); this.name = 'XmApiError'; + + // Ensure proper prototype chain for instanceof checks + Object.setPrototypeOf(this, XmApiError.prototype); + + // Capture stack trace + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } } } diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index 90542a3..d286382 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -2,10 +2,12 @@ import { assertEquals, assertExists, assertObjectMatch, + assertStringIncludes, } from 'https://deno.land/std@0.193.0/testing/asserts.ts'; import { GroupsEndpoint } from './index.ts'; import { GetGroupsResponse } from './types.ts'; import { createMockResponse, MockHttpHandler } from '../../core/test-utils.ts'; +import { XmApiError } from '../../core/types.ts'; const mockGroup = { id: '123', @@ -36,17 +38,18 @@ const mockGroupsResponse: GetGroupsResponse = { Deno.test('GroupsEndpoint', async (t) => { await t.step('getGroups without parameters', async () => { - const mockHttp = new MockHttpHandler(createMockResponse({ + const mockResponse = createMockResponse({ body: mockGroupsResponse, headers: { 'content-type': 'application/json', }, - })); + }); + const mockHttp = new MockHttpHandler(mockResponse); const endpoint = new GroupsEndpoint(mockHttp); const response = await endpoint.getGroups(); - assertEquals(response, mockGroupsResponse); + assertEquals(response.body, mockGroupsResponse); assertEquals(mockHttp.requests.length, 1); const request = mockHttp.requests[0]; @@ -56,18 +59,19 @@ Deno.test('GroupsEndpoint', async (t) => { }); await t.step('getGroups with parameters', async () => { - const mockHttp = new MockHttpHandler(createMockResponse({ + const mockResponse = createMockResponse({ body: mockGroupsResponse, headers: { 'content-type': 'application/json', }, - })); + }); + const mockHttp = new MockHttpHandler(mockResponse); const endpoint = new GroupsEndpoint(mockHttp); const params = { limit: 10, offset: 0, search: 'test' }; const response = await endpoint.getGroups(params); - assertEquals(response, mockGroupsResponse); + assertEquals(response.body, mockGroupsResponse); assertEquals(mockHttp.requests.length, 1); const request = mockHttp.requests[0]; @@ -96,11 +100,130 @@ Deno.test('GroupsEndpoint', async (t) => { throw new Error('Expected XmApiError but got: ' + String(error)); } assertEquals(error.name, 'XmApiError'); - assertEquals(error.message, 'Request failed with status 404'); + assertEquals(error.message, 'Not Found'); // Type assertion since we know it's an XmApiError - const xmError = error as { response?: { status: number } }; + const xmError = error as XmApiError; assertExists(xmError.response); assertEquals(xmError.response.status, 404); } }); }); + +Deno.test('GroupsEndpoint error handling', async (t) => { + await t.step('retries on rate limit with Retry-After', async () => { + const rateLimitResponse = createMockResponse({ + body: { message: 'Too many requests' }, + status: 429, + headers: { + 'retry-after': '2', + 'content-type': 'application/json', + }, + }); + const successResponse = createMockResponse({ + body: mockGroupsResponse, + headers: { + 'content-type': 'application/json', + }, + }); + + const mockHttp = new MockHttpHandler([rateLimitResponse, successResponse]); + const endpoint = new GroupsEndpoint(mockHttp); + + const response = await endpoint.getGroups(); + assertEquals(response.body, mockGroupsResponse); + assertEquals(mockHttp.requests.length, 2); + + // Verify retry was attempted + const [firstRequest, retryRequest] = mockHttp.requests; + assertEquals(firstRequest.retryAttempt, 0); + assertEquals(retryRequest.retryAttempt, 1); + }); + + await t.step('handles detailed error responses', async () => { + const errorResponse = createMockResponse({ + body: { + code: 'VALIDATION_ERROR', + message: 'Invalid input', + details: [ + { field: 'targetName', message: 'Must not be empty' }, + ], + }, + status: 400, + headers: { + 'content-type': 'application/json', + 'request-id': 'test-123', + }, + }); + const mockHttp = new MockHttpHandler(errorResponse); + const endpoint = new GroupsEndpoint(mockHttp); + + try { + await endpoint.getGroups(); + throw new Error('Expected error to be thrown'); + } catch (error) { + assertExists(error); + assertEquals(error instanceof XmApiError, true); + const xmError = error as XmApiError; + + // Verify error message has all the context + assertEquals(xmError.message, 'VALIDATION_ERROR: Invalid input'); + + // Verify response is preserved + assertExists(xmError.response); + assertEquals(xmError.response.status, 400); + assertEquals(xmError.response.headers['request-id'], 'test-123'); + } + }); + + await t.step('handles errors without response body', async () => { + const errorResponse = createMockResponse({ + body: '', // Empty response body + status: 502, + headers: { + 'content-type': 'text/plain', + }, + }); + const mockHttp = new MockHttpHandler(errorResponse); + const endpoint = new GroupsEndpoint(mockHttp); + + try { + await endpoint.getGroups(); + throw new Error('Expected error to be thrown'); + } catch (error) { + assertExists(error); + assertEquals(error instanceof XmApiError, true); + const xmError = error as XmApiError; + + // Verify fallback error message + assertStringIncludes(xmError.message, '502'); + assertExists(xmError.response); + assertEquals(xmError.response.status, 502); + assertEquals(xmError.response.body, ''); + } + }); + + await t.step('handles network errors', async () => { + const mockHttp = new MockHttpHandler({ + status: 0, // No status indicates network error + headers: {}, + body: undefined, + }); + mockHttp.forceError = new Error('Network error'); + + const endpoint = new GroupsEndpoint(mockHttp); + + try { + await endpoint.getGroups(); + throw new Error('Expected error to be thrown'); + } catch (error) { + assertExists(error); + assertEquals(error instanceof XmApiError, true); + const xmError = error as XmApiError; + + assertEquals(xmError.message, 'Request failed'); + assertEquals(xmError.response, undefined); + assertEquals(xmError.cause instanceof Error, true); + assertStringIncludes((xmError.cause as Error).message, 'Network error'); + } + }); +}); diff --git a/src/endpoints/groups/index.ts b/src/endpoints/groups/index.ts index 1bfa19e..4af4f75 100644 --- a/src/endpoints/groups/index.ts +++ b/src/endpoints/groups/index.ts @@ -1,5 +1,7 @@ +import { EndpointHttpHandler } from '../../core/endpoint-http.ts'; import { HttpHandler } from '../../core/http.ts'; -import { GetGroupsParams, GetGroupsResponse } from './types.ts'; +import type { HttpResponse } from '../../core/types.ts'; +import { GetGroupsParams, GetGroupsResponse, Group } from './types.ts'; /** * Provides access to the groups endpoints of the xMatters API. @@ -13,38 +15,69 @@ import { GetGroupsParams, GetGroupsResponse } from './types.ts'; * }); * * // Get all groups - * const groups = await xm.groups.getGroups(); + * const { body: groups } = await xm.groups.getGroups(); * * // Get groups with pagination - * const pagedGroups = await xm.groups.getGroups({ + * const { body: pagedGroups } = await xm.groups.getGroups({ * limit: 10, * offset: 0 * }); * * // Search for groups - * const searchedGroups = await xm.groups.getGroups({ + * const { body: searchedGroups } = await xm.groups.getGroups({ * search: 'oncall' * }); * ``` */ export class GroupsEndpoint { - /** Base path for all groups endpoints */ - private readonly basePath = '/groups'; + private readonly http: EndpointHttpHandler; - constructor(private readonly http: HttpHandler) {} + constructor(http: HttpHandler) { + this.http = new EndpointHttpHandler(http, '/groups'); + } /** * Get a list of groups from xMatters. * The results can be filtered and paginated using the params object. * - * @param options.params Optional parameters to filter and paginate the results - * @returns A paginated list of groups matching the filter criteria + * @param params Optional parameters to filter and paginate the results + * @returns The HTTP response containing a paginated list of groups + * @throws {XmApiError} If the request fails + */ + getGroups(params?: GetGroupsParams): Promise> { + return this.http.get({ query: params }); + } + + /** + * Get a group by ID + * + * @param id The ID of the group to retrieve + * @returns The HTTP response containing the group + * @throws {XmApiError} If the request fails + */ + getById(id: string): Promise> { + return this.http.get({ path: id }); + } + + /** + * Create a new group or update an existing one + * + * @param group The group to create or update + * @returns The HTTP response containing the created or updated group + * @throws {XmApiError} If the request fails + */ + save(group: Partial): Promise> { + return this.http.post({ body: group }); + } + + /** + * Delete a group by ID + * + * @param id The ID of the group to delete + * @returns The HTTP response * @throws {XmApiError} If the request fails */ - getGroups(params?: GetGroupsParams): Promise { - return this.http.get({ - path: this.basePath, - query: params ? { ...params } : undefined, - }); + delete(id: string): Promise> { + return this.http.delete({ path: id }); } } From 567d132705f34e99844f660bca31c7b843795ed8 Mon Sep 17 00:00:00 2001 From: Johan Date: Thu, 5 Jun 2025 01:06:21 -0700 Subject: [PATCH 008/101] Rename FTW --- README.md | 71 ++++++++++++------- docs/improvements.md | 12 ++-- .../{http.test.ts => request-handler.test.ts} | 16 ++--- src/core/{http.ts => request-handler.ts} | 2 +- .../{endpoint-http.ts => resource-client.ts} | 9 +-- src/core/test-utils.ts | 4 +- src/endpoints/groups/index.test.ts | 16 ++--- src/endpoints/groups/index.ts | 10 +-- src/index.ts | 11 ++- 9 files changed, 87 insertions(+), 64 deletions(-) rename src/core/{http.test.ts => request-handler.test.ts} (91%) rename src/core/{http.ts => request-handler.ts} (99%) rename src/core/{endpoint-http.ts => resource-client.ts} (85%) diff --git a/README.md b/README.md index bbf208e..595cb76 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,14 @@ # xM API SDK JS + `xmas` for short 🎄 # Usage -If your project already relies on the `axios` library, -`xmas` will just use it to send http requests to `xmApi` and handle its responses. + +If your project already relies on the `axios` library, `xmas` will just use it to send http requests +to `xmApi` and handle its responses. Your instantiation of a new Xmas object will only need your xM hostname and some auth credentials: + ```js const Xmas = require('xmas'); @@ -24,8 +27,9 @@ xmas.groups.create(group) .catch(handleError); ``` -Alternative `config` object for **OAuth** -(say when you have already generated tokens and safely stored them in your DB): +Alternative `config` object for **OAuth** (say when you have already generated tokens and safely +stored them in your DB): + ```json { "hostname": "https://yourOrg.xmatters.com", @@ -36,6 +40,7 @@ Alternative `config` object for **OAuth** ``` ## Obtain OAuth tokens + ```js const Xmas = require('xmas'); @@ -51,12 +56,15 @@ xmas.getOauthTokens.byUsernamePassword() .then(() => xmas.people.search('immediately uses the tokens, not the creds set in config')) .catch(handleError); ``` -`Xmas` will immediately start using the OAuth tokens and stop using the username & password -you instantiated it with. + +`Xmas` will immediately start using the OAuth tokens and stop using the username & password you +instantiated it with. ## Dependency injection -If your project relies on an **HTTP client** *other* than `axios`, -you will need to pass it in the `config` when you instantiate an Xmas: + +If your project relies on an **HTTP client** _other_ than `axios`, you will need to pass it in the +`config` when you instantiate an Xmas: + ```js const config = { hostname: 'https://yourOrg.xmatters.com', @@ -66,46 +74,55 @@ const config = { sendRequest: yourHttpClient, successAdapter: () => {}, failureAdapter: () => {}, - } + }, }; ``` ### httpClient.sendRequet + Should have the following signature: + ```js -({ method, url, headers, data }) => Promise +(({ method, url, headers, data }) => Promise); ``` + Where: -+ `method` will be an HTTP method used to send the request (eg: 'GET', 'POST', 'DELETE') -+ `url` will be the fully qualified url the request will be sent to -(eg: 'https://yourOrg.xmatters.com/api/xm/1/people?firstName=peter&lastName=parker') -+ `headers` will be a typical HTTP request headers object to send to xM API -+ `data` (optional) will be the stringified payload to send to xM API + +- `method` will be an HTTP method used to send the request (eg: 'GET', 'POST', 'DELETE') +- `url` will be the fully qualified url the request will be sent to (eg: + 'https://yourOrg.xmatters.com/api/xm/1/people?firstName=peter&lastName=parker') +- `headers` will be a typical HTTP request headers object to send to xM API +- `data` (optional) will be the stringified payload to send to xM API Your HTTP client should know what to do with those and must return a `promise`. ### httpClient.successAdapter -This is a function that will receive the response -in the exact format your HTTP client usually returns it upon a successful request (2xx). + +This is a function that will receive the response in the exact format your HTTP client usually +returns it upon a successful request (2xx). Think the very first `.then()` called when your HTTP client promise `resolves`. -This function must *only* return the xmApi `payload`/`response body`. +This function must _only_ return the xmApi `payload`/`response body`. Here is an example of the adapter used for the axios HTTP client under the hood: + ```js const axiosSuccessAdapter = (res) => res.data; ``` ### httpClient.failureAdapter -This is a function that will receive the error -in the exact format your HTTP client usually throws it upon a failed request (non 2xx). + +This is a function that will receive the error in the exact format your HTTP client usually throws +it upon a failed request (non 2xx). Think the `.catch()` called when your HTTP client promise `rejects`. -This function must throw (rethrow, technically) an error object with both a `status` and a `payload` property attached to it. +This function must throw (rethrow, technically) an error object with both a `status` and a `payload` +property attached to it. Here is an example of the adapter used for the axios HTTP client under the hood: + ```js const axiosFailureAdapter = (e) => { const humanReadableMessage = e.response @@ -117,13 +134,13 @@ const axiosFailureAdapter = (e) => { throw error; }; ``` + The human readable message can be omitted and the object thrown doesn't even have to be an Error -instance, these are just nice things. -What is important is that this function `throws`, -and that the object thrown contains a `status` and a `payload` property. -Where: -+ `status` must be an *integer*: the http **status code** of the response -+ `payload` must be the xM API **response body** if one was returned +instance, these are just nice things. What is important is that this function `throws`, and that the +object thrown contains a `status` and a `payload` property. Where: + +- `status` must be an _integer_: the http **status code** of the response +- `payload` must be the xM API **response body** if one was returned ```sh # If all of this seems like more trouble than having to manage 1 more dependency in your project, diff --git a/docs/improvements.md b/docs/improvements.md index f36802b..0e62ca0 100644 --- a/docs/improvements.md +++ b/docs/improvements.md @@ -2,7 +2,7 @@ ## HTTP Handler Retry Strategy -Currently, the HttpHandler implements a built-in retry strategy. Here are the potential improvements +Currently, the RequestHandler implements a built-in retry strategy. Here are the potential improvements to consider with detailed implementation notes. ### Option 1: Keep Built-in Strategy but Make it More Configurable @@ -28,7 +28,7 @@ export interface RetryConfig { } ``` -2. Update HttpHandler constructor to accept this config: +2. Update RequestHandler constructor to accept this config: ```typescript constructor( @@ -47,7 +47,7 @@ constructor( Implementation details: -1. Add RetryingHttpClient class to `src/core/http-client.ts`: +1. Add RetryingHttpClient class to `src/core/request-handler.ts`: ```typescript export class RetryingHttpClient implements HttpClient { @@ -59,7 +59,7 @@ export class RetryingHttpClient implements HttpClient { } ``` -2. Move all retry-related code from HttpHandler to this class +2. Move all retry-related code from RequestHandler to this class 3. Update DefaultHttpClient to optionally wrap itself with RetryingHttpClient 4. Add examples in README showing how to implement custom retry strategies @@ -67,7 +67,7 @@ export class RetryingHttpClient implements HttpClient { Implementation details: -1. Keep basic retry logic in HttpHandler but only for token refresh +1. Keep basic retry logic in RequestHandler but only for token refresh 2. Allow wrapping any HttpClient with retry behavior: ```typescript @@ -76,7 +76,7 @@ const retryingClient = new RetryingHttpClient(client, { maxRetries: 3, shouldRetry: (res) => res.status === 429 }); -const handler = new HttpHandler(retryingClient, ...); +const handler = new RequestHandler(retryingClient, ...); ``` 3. Provide common retry strategies as utilities: diff --git a/src/core/http.test.ts b/src/core/request-handler.test.ts similarity index 91% rename from src/core/http.test.ts rename to src/core/request-handler.test.ts index dcfa2bb..9c92817 100644 --- a/src/core/http.test.ts +++ b/src/core/request-handler.test.ts @@ -3,7 +3,7 @@ import { assertExists, assertRejects, } from 'https://deno.land/std@0.193.0/testing/asserts.ts'; -import { HttpHandler, RequestBuilder } from './http.ts'; +import { RequestBuilder, RequestHandler } from './request-handler.ts'; import { HttpClient, HttpRequest, HttpResponse, Logger, XmApiError } from './types.ts'; class TestHttpClient implements HttpClient { @@ -27,11 +27,11 @@ const mockLogger: Logger = { error: () => {}, }; -Deno.test('HttpHandler', async (t) => { +Deno.test('RequestHandler', async (t) => { await t.step('handles non-JSON response bodies', async () => { const client = new TestHttpClient(); const requestBuilder = new RequestBuilder('https://example.com'); - const handler = new HttpHandler(client, mockLogger, requestBuilder); + const handler = new RequestHandler(client, mockLogger, requestBuilder); client.responses = [{ status: 400, @@ -49,7 +49,7 @@ Deno.test('HttpHandler', async (t) => { await t.step('retries on rate limit with Retry-After', async () => { const client = new TestHttpClient(); const requestBuilder = new RequestBuilder('https://example.com'); - const handler = new HttpHandler(client, mockLogger, requestBuilder); + const handler = new RequestHandler(client, mockLogger, requestBuilder); client.responses = [ { @@ -73,7 +73,7 @@ Deno.test('HttpHandler', async (t) => { await t.step('retries with exponential backoff on server error', async () => { const client = new TestHttpClient(); const requestBuilder = new RequestBuilder('https://example.com'); - const handler = new HttpHandler(client, mockLogger, requestBuilder); + const handler = new RequestHandler(client, mockLogger, requestBuilder); client.responses = [ { @@ -97,7 +97,7 @@ Deno.test('HttpHandler', async (t) => { await t.step('stops retrying after max attempts', async () => { const client = new TestHttpClient(); const requestBuilder = new RequestBuilder('https://example.com'); - const handler = new HttpHandler(client, mockLogger, requestBuilder); + const handler = new RequestHandler(client, mockLogger, requestBuilder); client.responses = Array(5).fill({ status: 503, @@ -117,7 +117,7 @@ Deno.test('HttpHandler', async (t) => { await t.step('handles network errors', async () => { const client = new TestHttpClient(); const requestBuilder = new RequestBuilder('https://example.com'); - const handler = new HttpHandler(client, mockLogger, requestBuilder); + const handler = new RequestHandler(client, mockLogger, requestBuilder); client.forceError = new Error('Network error'); @@ -138,7 +138,7 @@ Deno.test('HttpHandler', async (t) => { await t.step('refreshes token on 401 response', async () => { const client = new TestHttpClient(); const requestBuilder = new RequestBuilder('https://example.com'); - const handler = new HttpHandler( + const handler = new RequestHandler( client, mockLogger, requestBuilder, diff --git a/src/core/http.ts b/src/core/request-handler.ts similarity index 99% rename from src/core/http.ts rename to src/core/request-handler.ts index c5e1c94..64f973b 100644 --- a/src/core/http.ts +++ b/src/core/request-handler.ts @@ -99,7 +99,7 @@ export class RequestBuilder { } } -export class HttpHandler { +export class RequestHandler { /** Current token state if using OAuth */ private tokenState?: TokenState; diff --git a/src/core/endpoint-http.ts b/src/core/resource-client.ts similarity index 85% rename from src/core/endpoint-http.ts rename to src/core/resource-client.ts index 475cc95..c40f445 100644 --- a/src/core/endpoint-http.ts +++ b/src/core/resource-client.ts @@ -1,13 +1,14 @@ import type { DeleteOptions, GetOptions, HttpRequest, RequestWithBodyOptions } from './types.ts'; -import { HttpHandler } from './http.ts'; +import { RequestHandler } from './request-handler.ts'; /** - * A wrapper around HttpHandler that automatically prepends a base path to all requests. + * A wrapper around RequestHandler that automatically prepends a base path to all requests. + * Each API resource (endpoint) gets its own instance of this client to handle resource-specific paths. * This allows endpoint classes to focus on their specific paths without repeating the base path. */ -export class EndpointHttpHandler { +export class ResourceClient { constructor( - private readonly http: HttpHandler, + private readonly http: RequestHandler, private readonly basePath: string, ) { if (!basePath.startsWith('/')) { diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts index 9f3437a..f9be22e 100644 --- a/src/core/test-utils.ts +++ b/src/core/test-utils.ts @@ -6,7 +6,7 @@ import { XmApiError, XmApiOptions, } from './types.ts'; -import { HttpHandler, RequestBuilder } from './http.ts'; +import { RequestBuilder, RequestHandler } from './request-handler.ts'; class MockRequestBuilder extends RequestBuilder { constructor() { @@ -26,7 +26,7 @@ class MockRequestBuilder extends RequestBuilder { } } -export class MockHttpHandler extends HttpHandler { +export class MockRequestHandler extends RequestHandler { public readonly requests: HttpRequest[] = []; private readonly responses: HttpResponse[]; public forceError?: Error; diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index d286382..4d924d9 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -6,7 +6,7 @@ import { } from 'https://deno.land/std@0.193.0/testing/asserts.ts'; import { GroupsEndpoint } from './index.ts'; import { GetGroupsResponse } from './types.ts'; -import { createMockResponse, MockHttpHandler } from '../../core/test-utils.ts'; +import { createMockResponse, MockRequestHandler } from '../../core/test-utils.ts'; import { XmApiError } from '../../core/types.ts'; const mockGroup = { @@ -44,7 +44,7 @@ Deno.test('GroupsEndpoint', async (t) => { 'content-type': 'application/json', }, }); - const mockHttp = new MockHttpHandler(mockResponse); + const mockHttp = new MockRequestHandler(mockResponse); const endpoint = new GroupsEndpoint(mockHttp); const response = await endpoint.getGroups(); @@ -65,7 +65,7 @@ Deno.test('GroupsEndpoint', async (t) => { 'content-type': 'application/json', }, }); - const mockHttp = new MockHttpHandler(mockResponse); + const mockHttp = new MockRequestHandler(mockResponse); const endpoint = new GroupsEndpoint(mockHttp); const params = { limit: 10, offset: 0, search: 'test' }; @@ -89,7 +89,7 @@ Deno.test('GroupsEndpoint', async (t) => { 'content-type': 'application/json', }, }); - const mockHttp = new MockHttpHandler(errorResponse); + const mockHttp = new MockRequestHandler(errorResponse); const endpoint = new GroupsEndpoint(mockHttp); try { @@ -126,7 +126,7 @@ Deno.test('GroupsEndpoint error handling', async (t) => { }, }); - const mockHttp = new MockHttpHandler([rateLimitResponse, successResponse]); + const mockHttp = new MockRequestHandler([rateLimitResponse, successResponse]); const endpoint = new GroupsEndpoint(mockHttp); const response = await endpoint.getGroups(); @@ -154,7 +154,7 @@ Deno.test('GroupsEndpoint error handling', async (t) => { 'request-id': 'test-123', }, }); - const mockHttp = new MockHttpHandler(errorResponse); + const mockHttp = new MockRequestHandler(errorResponse); const endpoint = new GroupsEndpoint(mockHttp); try { @@ -183,7 +183,7 @@ Deno.test('GroupsEndpoint error handling', async (t) => { 'content-type': 'text/plain', }, }); - const mockHttp = new MockHttpHandler(errorResponse); + const mockHttp = new MockRequestHandler(errorResponse); const endpoint = new GroupsEndpoint(mockHttp); try { @@ -203,7 +203,7 @@ Deno.test('GroupsEndpoint error handling', async (t) => { }); await t.step('handles network errors', async () => { - const mockHttp = new MockHttpHandler({ + const mockHttp = new MockRequestHandler({ status: 0, // No status indicates network error headers: {}, body: undefined, diff --git a/src/endpoints/groups/index.ts b/src/endpoints/groups/index.ts index 4af4f75..4eeecf5 100644 --- a/src/endpoints/groups/index.ts +++ b/src/endpoints/groups/index.ts @@ -1,5 +1,5 @@ -import { EndpointHttpHandler } from '../../core/endpoint-http.ts'; -import { HttpHandler } from '../../core/http.ts'; +import { ResourceClient } from '../../core/resource-client.ts'; +import { RequestHandler } from '../../core/request-handler.ts'; import type { HttpResponse } from '../../core/types.ts'; import { GetGroupsParams, GetGroupsResponse, Group } from './types.ts'; @@ -30,10 +30,10 @@ import { GetGroupsParams, GetGroupsResponse, Group } from './types.ts'; * ``` */ export class GroupsEndpoint { - private readonly http: EndpointHttpHandler; + private readonly http: ResourceClient; - constructor(http: HttpHandler) { - this.http = new EndpointHttpHandler(http, '/groups'); + constructor(http: RequestHandler) { + this.http = new ResourceClient(http, '/groups'); } /** diff --git a/src/index.ts b/src/index.ts index dccaadf..70b9231 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,9 @@ -import { DefaultHttpClient, defaultLogger, HttpHandler, RequestBuilder } from './core/http.ts'; +import { + DefaultHttpClient, + defaultLogger, + RequestBuilder, + RequestHandler, +} from './core/request-handler.ts'; import { XmApiOptions } from './core/types.ts'; import { GroupsEndpoint } from './endpoints/groups/index.ts'; @@ -39,7 +44,7 @@ import { GroupsEndpoint } from './endpoints/groups/index.ts'; */ export class XmApi { /** HTTP handler that manages all API requests */ - private readonly http: HttpHandler; + private readonly http: RequestHandler; /** Access groups-related endpoints */ public readonly groups: GroupsEndpoint; @@ -81,7 +86,7 @@ export class XmApi { // Get onTokenRefresh callback if using OAuth const onTokenRefresh = 'onTokenRefresh' in options ? options.onTokenRefresh : undefined; - this.http = new HttpHandler(httpClient, logger, requestBuilder, maxRetries, onTokenRefresh); + this.http = new RequestHandler(httpClient, logger, requestBuilder, maxRetries, onTokenRefresh); // Initialize endpoints this.groups = new GroupsEndpoint(this.http); From d1d8bf60aa3d60bb7ee80997c694c9d61aa3cb8f Mon Sep 17 00:00:00 2001 From: Johan Date: Thu, 5 Jun 2025 21:28:25 -0700 Subject: [PATCH 009/101] Move XmApiError out of the types.ts file --- docs/improvements.md | 4 ++-- src/core/errors.ts | 34 +++++++++++++++++++++++++++++ src/core/request-handler.test.ts | 3 ++- src/core/request-handler.ts | 2 +- src/core/test-utils.ts | 10 ++------- src/core/types.ts | 35 ------------------------------ src/endpoints/groups/index.test.ts | 2 +- 7 files changed, 42 insertions(+), 48 deletions(-) create mode 100644 src/core/errors.ts diff --git a/docs/improvements.md b/docs/improvements.md index 0e62ca0..818f618 100644 --- a/docs/improvements.md +++ b/docs/improvements.md @@ -2,8 +2,8 @@ ## HTTP Handler Retry Strategy -Currently, the RequestHandler implements a built-in retry strategy. Here are the potential improvements -to consider with detailed implementation notes. +Currently, the RequestHandler implements a built-in retry strategy. Here are the potential +improvements to consider with detailed implementation notes. ### Option 1: Keep Built-in Strategy but Make it More Configurable diff --git a/src/core/errors.ts b/src/core/errors.ts new file mode 100644 index 0000000..53a2420 --- /dev/null +++ b/src/core/errors.ts @@ -0,0 +1,34 @@ +/** + * Base class for all errors thrown by the xMatters API client. + * Contains information about the failed request and response. + */ +export class XmApiError extends Error { + /** + * @param message Human-readable error message + * @param response Optional response details if the error occurred after receiving a response + * @param cause Optional underlying error that caused this error + */ + constructor( + message: string, + public readonly response?: { + /** The response body as a string */ + body: string; + /** The HTTP status code that triggered this error */ + status: number; + /** Response headers that may contain additional error context */ + headers: Record; + }, + public override readonly cause?: unknown, + ) { + super(message); + this.name = 'XmApiError'; + + // Ensure proper prototype chain for instanceof checks + Object.setPrototypeOf(this, XmApiError.prototype); + + // Capture stack trace + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } +} diff --git a/src/core/request-handler.test.ts b/src/core/request-handler.test.ts index 9c92817..6f4543e 100644 --- a/src/core/request-handler.test.ts +++ b/src/core/request-handler.test.ts @@ -4,7 +4,8 @@ import { assertRejects, } from 'https://deno.land/std@0.193.0/testing/asserts.ts'; import { RequestBuilder, RequestHandler } from './request-handler.ts'; -import { HttpClient, HttpRequest, HttpResponse, Logger, XmApiError } from './types.ts'; +import { HttpClient, HttpRequest, HttpResponse, Logger } from './types.ts'; +import { XmApiError } from './errors.ts'; class TestHttpClient implements HttpClient { public requests: HttpRequest[] = []; diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index 64f973b..fe7b6a8 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -6,8 +6,8 @@ import { HttpResponse, Logger, RequestWithBodyOptions, - XmApiError, } from './types.ts'; +import { XmApiError } from './errors.ts'; import { TokenData, TokenState } from './oauth-types.ts'; export class DefaultHttpClient implements HttpClient { diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts index f9be22e..ac050d8 100644 --- a/src/core/test-utils.ts +++ b/src/core/test-utils.ts @@ -1,11 +1,5 @@ -import { - HttpClient, - HttpRequest, - HttpResponse, - Logger, - XmApiError, - XmApiOptions, -} from './types.ts'; +import { HttpClient, HttpRequest, HttpResponse, Logger, XmApiOptions } from './types.ts'; +import { XmApiError } from './errors.ts'; import { RequestBuilder, RequestHandler } from './request-handler.ts'; class MockRequestBuilder extends RequestBuilder { diff --git a/src/core/types.ts b/src/core/types.ts index f293698..0d1e7e3 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -68,41 +68,6 @@ export function isOAuthOptions(options: XmApiOptions): options is XmApiOAuthOpti return 'accessToken' in options; } -/** - * Base class for all errors thrown by the xMatters API client. - * Contains information about the failed request and response. - */ -export class XmApiError extends Error { - /** - * @param message Human-readable error message - * @param response Optional response details if the error occurred after receiving a response - * @param cause Optional underlying error that caused this error - */ - constructor( - message: string, - public readonly response?: { - /** The response body as a string */ - body: string; - /** The HTTP status code that triggered this error */ - status: number; - /** Response headers that may contain additional error context */ - headers: Record; - }, - public override readonly cause?: unknown, - ) { - super(message); - this.name = 'XmApiError'; - - // Ensure proper prototype chain for instanceof checks - Object.setPrototypeOf(this, XmApiError.prototype); - - // Capture stack trace - if (Error.captureStackTrace) { - Error.captureStackTrace(this, this.constructor); - } - } -} - /** * Base interface for all HTTP method options */ diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index 4d924d9..86442a6 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -7,7 +7,7 @@ import { import { GroupsEndpoint } from './index.ts'; import { GetGroupsResponse } from './types.ts'; import { createMockResponse, MockRequestHandler } from '../../core/test-utils.ts'; -import { XmApiError } from '../../core/types.ts'; +import { XmApiError } from '../../core/errors.ts'; const mockGroup = { id: '123', From 1b8184af5e4b75479ae70b52f9ef196017e2c126 Mon Sep 17 00:00:00 2001 From: Johan Date: Thu, 5 Jun 2025 21:41:34 -0700 Subject: [PATCH 010/101] Move DefaultHttpClient and defaultLogger out of request-handler.ts to remain congruent with their optionality --- src/core/defaults/http-client.ts | 49 ++++++++++++++++++++++++++++ src/core/defaults/index.ts | 2 ++ src/core/defaults/logger.ts | 8 +++++ src/core/request-handler.ts | 55 -------------------------------- src/index.ts | 8 ++--- 5 files changed, 61 insertions(+), 61 deletions(-) create mode 100644 src/core/defaults/http-client.ts create mode 100644 src/core/defaults/index.ts create mode 100644 src/core/defaults/logger.ts diff --git a/src/core/defaults/http-client.ts b/src/core/defaults/http-client.ts new file mode 100644 index 0000000..2f4065a --- /dev/null +++ b/src/core/defaults/http-client.ts @@ -0,0 +1,49 @@ +import { HttpClient, HttpRequest, HttpResponse } from '../types.ts'; + +export class DefaultHttpClient implements HttpClient { + async send(request: HttpRequest): Promise { + // Handle body serialization based on content type + let serializedBody: string | undefined; + const contentType = request.headers?.['content-type']; + if (request.body) { + if (contentType?.includes('application/json')) { + serializedBody = JSON.stringify(request.body); + } else if (typeof request.body === 'string') { + serializedBody = request.body; + } else { + // Default to JSON if no content type specified + serializedBody = JSON.stringify(request.body); + } + } + + const response = await fetch(request.url, { + method: request.method, + headers: request.headers, + body: serializedBody, + }); + + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + + let body: unknown; + const responseType = headers['content-type']; + if (responseType?.includes('application/json')) { + try { + body = await response.json(); + } catch (_e) { + // If JSON parsing fails, fall back to text + body = await response.text(); + } + } else { + body = await response.text(); + } + + return { + status: response.status, + headers, + body, + }; + } +} diff --git a/src/core/defaults/index.ts b/src/core/defaults/index.ts new file mode 100644 index 0000000..2c7424e --- /dev/null +++ b/src/core/defaults/index.ts @@ -0,0 +1,2 @@ +export { DefaultHttpClient } from './http-client.ts'; +export { defaultLogger } from './logger.ts'; diff --git a/src/core/defaults/logger.ts b/src/core/defaults/logger.ts new file mode 100644 index 0000000..f17abb2 --- /dev/null +++ b/src/core/defaults/logger.ts @@ -0,0 +1,8 @@ +import { Logger } from '../types.ts'; + +export const defaultLogger: Logger = { + debug: console.debug.bind(console), + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), +}; diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index fe7b6a8..71f2f96 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -10,61 +10,6 @@ import { import { XmApiError } from './errors.ts'; import { TokenData, TokenState } from './oauth-types.ts'; -export class DefaultHttpClient implements HttpClient { - async send(request: HttpRequest): Promise { - // Handle body serialization based on content type - let serializedBody: string | undefined; - const contentType = request.headers?.['content-type']; - if (request.body) { - if (contentType?.includes('application/json')) { - serializedBody = JSON.stringify(request.body); - } else if (typeof request.body === 'string') { - serializedBody = request.body; - } else { - // Default to JSON if no content type specified - serializedBody = JSON.stringify(request.body); - } - } - - const response = await fetch(request.url, { - method: request.method, - headers: request.headers, - body: serializedBody, - }); - - const headers: Record = {}; - response.headers.forEach((value, key) => { - headers[key] = value; - }); - - let body: unknown; - const responseType = headers['content-type']; - if (responseType?.includes('application/json')) { - try { - body = await response.json(); - } catch (e) { - // If JSON parsing fails, fall back to text - body = await response.text(); - } - } else { - body = await response.text(); - } - - return { - status: response.status, - headers, - body, - }; - } -} - -export const defaultLogger: Logger = { - debug: console.debug.bind(console), - info: console.info.bind(console), - warn: console.warn.bind(console), - error: console.error.bind(console), -}; - export class RequestBuilder { private readonly apiVersionPath = '/api/xm/1'; diff --git a/src/index.ts b/src/index.ts index 70b9231..dfd8a13 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,5 @@ -import { - DefaultHttpClient, - defaultLogger, - RequestBuilder, - RequestHandler, -} from './core/request-handler.ts'; +import { RequestBuilder, RequestHandler } from './core/request-handler.ts'; +import { DefaultHttpClient, defaultLogger } from './core/defaults/index.ts'; import { XmApiOptions } from './core/types.ts'; import { GroupsEndpoint } from './endpoints/groups/index.ts'; From bf3f81b6f6bdfea4bba81dd1ca21b69288fb9bea Mon Sep 17 00:00:00 2001 From: Johan Date: Thu, 5 Jun 2025 22:18:47 -0700 Subject: [PATCH 011/101] Take RequestBuilder out of request-hander.ts Make it possible for maintainers to use an escape hatch to create method that send request with a consumer-provided fully-qualified URL (We're going to need that for the integrations.trigger method later) --- .github/copilot-instructions.md | 206 +++++++++++++++++++++++++++++++ src/core/request-builder.test.ts | 73 +++++++++++ src/core/request-builder.ts | 74 +++++++++++ src/core/request-handler.test.ts | 11 +- src/core/request-handler.ts | 38 +----- src/core/test-utils.ts | 22 ++-- src/index.ts | 3 +- 7 files changed, 377 insertions(+), 50 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 src/core/request-builder.test.ts create mode 100644 src/core/request-builder.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..0c684ad --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,206 @@ +This typescript project built with deno is meant to be a library for javascript developers to +consume the xMatters API (AKA xmApi). + +The priorities for this project are: + +1. **Consistency**: Both from the library developer's perspective and the library consumer's + perspective, code should be consistent in style, structure, and behavior. This includes + consistent naming conventions, error handling, and response structures. +2. **Zero Dependencies**: The library should not depend on any other libraries, except for Deno's + standard library and even then, only for unit testing. +3. **Dependency Injection**: The consumer should be able to inject their own HTTP client, logger, + and other dependencies. +4. **Type Safety**: The library should be fully type-safe, leveraging TypeScript's capabilities to + ensure that consumers get the best developer experience. +5. **Documentation**: The library should be well-documented, with clear examples and usage + instructions. + +We need to implement something that will make it extremely easy for the maintainers to add more +endpoints in the future. The core logic of how a request is built and sent should be abstracted away +from the endpoint implementations. + +For iterative development, you can make use of the /sandbox/index.ts file to test your code. This +file is not part of the library and is meant for quick prototyping and testing. Do not modify the +sandbox unless explicitly instructed to do so. + +The following code snippet, highly imperfect as it is, serves as _inspiration_ for some aspects of +the dependency injections. + +It contains some good ideas, but certainly has many shortcomings that we should avoid in our +implementation. + +```javascript +const nodePath = require('path'); + +const urlBuilder = (baseUrl) => ({ url = '', pathParams = [], queryParams = {} } = {}) => { + let finalUrl; + try { + // check if consumer intends to pass a full url (thereby ignoring baseUrl for this request) + const reqUrl = new URL(url); + finalUrl = new URL(nodePath.join(reqUrl.href, ...pathParams)); + } catch (e) { + // check if consumer passed a url without a protocol (e.g. 'google.com') + if (/\.([a-z]{2,})$/i.test(url)) { + console.warn( + "Did you intend to pass a full url? Make sure to include 'http://' or 'https://'.", + ); + } + // if consumer did not pass a full url, use baseUrl and assume url is a relative path + const base = new URL(baseUrl || 'about:blank'); + finalUrl = new URL(nodePath.join(base.href, url, ...pathParams)); + } + Object.entries(queryParams).forEach(([key, value]) => finalUrl.searchParams.set(key, value)); + return finalUrl.toString(); +}; + +const headersBuilder = ( + setDefaultHeaders, + setFixedHeaders, +) => +({ headers } = {}) => { + // Allow setting headers as null to send headerless requests + if (setDefaultHeaders && headers === undefined) { + headers = setDefaultHeaders(); + } + if (setFixedHeaders) { + if (headers) { + // Allow overriding fixedHeaders on a per-request basis + headers = { ...setFixedHeaders(), ...headers }; + // TODO: think some more about this. There may or may not be value in pushing consumers + // to pass a setDefaultHeaders + } else if (headers === undefined) { + headers = setFixedHeaders(); + } + } + return headers; +}; + +const reqBuilder = (buildUrl, buildHeaders) => (reqElements = {}) => { + const req = { + method: reqElements.method ? reqElements.method.toUpperCase() : 'GET', + url: buildUrl(reqElements), + headers: buildHeaders(reqElements), + attemptNumber: reqElements.attemptNumber || 1, + }; + if (reqElements.data) { + const { data } = reqElements; + req.data = typeof data === 'string' ? data : JSON.stringify(data); + } + return req; +}; + +const getBuilder = ({ + requestAdapter, + responseAdapter, + errorAdapter, +} = {}) => +({ + baseUrl, + successHandler, + failureHandler, + setDefaultHeaders, + setFixedHeaders, +} = {}) => { + const buildUrl = urlBuilder(baseUrl); + const buildHeaders = headersBuilder(setDefaultHeaders, setFixedHeaders); + const buildReq = reqBuilder(buildUrl, buildHeaders); + const send = (reqElements) => { + const req = buildReq(reqElements); + return requestAdapter(req) + .then(responseAdapter) + .catch(errorAdapter) + .then((res) => successHandler ? successHandler(res, req) : res) + .catch((err) => { + if (failureHandler) { + return failureHandler(err, req); + } + throw err; + }); + }; + return { + send, + get: (url, { queryParams, pathParams, headers } = {}) => + send({ url, queryParams, pathParams, headers }), + post: (url, data, { pathParams, headers } = {}) => + send({ method: 'post', url, pathParams, headers, data }), + put: (url, data, { pathParams, headers } = {}) => + send({ method: 'put', url, pathParams, headers, data }), + patch: (url, data, { pathParams, headers } = {}) => + send({ method: 'patch', url, pathParams, headers, data }), + delete: (url, { pathParams, headers } = {}) => + send({ method: 'delete', url, pathParams, headers }), + options: (url, { pathParams, headers } = {}) => + send({ method: 'options', url, pathParams, headers }), + }; +}; + +module.exports = getBuilder; +``` + +Features: + +- http client injection with adapters for request, response, and error handling +- use fetch as the default HTTP client (but even then fetch should be injectable and come with + adapters) +- logger injection +- use console as the default logger +- when a request fails with a response, there should be retry logic that: + - retries the request up to a configurable maximum number of attempts + - if it's a 401 Unauthorized error, it should retry the request after refreshing the access token +- perfectly consistant errors for the consumer + - the error should be a subclass of Error as to always have a message property populated + - the error should have a response property that is either undefined or an object with the + following properties: + - body: the response body as a string + - status: the HTTP status code of the response + - headers: an object containing the response headers + - Because consumers can inject their own HTTP client: + - the error should not contain any information about the HTTP client used + - the consumers should be able to specify when the error is thrown. Most likely in their http + client adapters. For eg: the Slack API almost never responds with a 4xx or 5xx status code, so + the consumer should be able to specify that the error should be thrown on 200 status code when + the response body contains the `ok` property set to `false`. +- For maintainers: + - the code should be easy to extend with new endpoints + - each new endpoint should have access to the same request building logic (e.g. url building, + headers building, etc. and reusable request sending logic via get, post, put, patch, delete + methods) + - We should make it easy for maintainers not to have to repeat themselves when adding new + endpoints + - for example, if adding a new /people endpoint, which would have methods such as + `getDevices`, the maintainers should not have to specify "/people/devices" when "/devices" + would suffice and the library should automatically prepend "/people" to the path + - the code should be easy to test + - the unit test should not send actual HTTP requests over the network + - the unit test should be based around something such as + "expect(sendRequest).toHaveBeenCalledWith(httpRequest)" so that the test is not dependent on + the actual HTTP client used + - each endpoint will be defined in its own directory inside the `endpoints` directory + - `types.ts` file that exports the endpoint's types + - `index.ts` file that exports the endpoint's methods + - `index.test.ts` file that contains the unit tests for the endpoint +- For consumers: + - they should always be able to provide a params object which is to be used to build a query + string + - if maintainers have relied on a params object to implement and endpoint, when consumers + provide a params object it should be merged with the default params for that endpoint + - it should be possible for the maintainers to make some of the params overrideable by the + consumer, while others should be fixed + - for example, if the endpoint is `/people/{id}/devices`, the `id` param should be fixed and + not overrideable by the consumer, while the `type` param should be overrideable by the + consumer + - they should be able to provide a headers object: + - as a config option to specify default headers for all requests + - as a per-request option to override the default headers + - the headers should be merged with the default headers, with the per-request headers taking + precedence + - If headers are not provided, the default headers should be used, but if they're explicitly + set to `null`, no headers should be sent + - they should be able to provide a function that will be called when oauth tokens get refreshed + - this function should be called with the new access token and the refresh token as arguments + - the function should only be called if it was provided by the consumer + - the function should be wrapped in a try-catch block to ensure that any errors thrown by the + function do not crash the library + +Do not make any changes until you have 95% confidence that you know what to build. Ask me follow up +questions until you have that confidence. diff --git a/src/core/request-builder.test.ts b/src/core/request-builder.test.ts new file mode 100644 index 0000000..1a14eb4 --- /dev/null +++ b/src/core/request-builder.test.ts @@ -0,0 +1,73 @@ +import { assertEquals, assertThrows } from 'https://deno.land/std@0.193.0/testing/asserts.ts'; +import { RequestBuilder } from './request-builder.ts'; + +Deno.test('RequestBuilder', async (t) => { + const builder = new RequestBuilder('https://example.com', { + 'default-header': 'value', + }); + + await t.step('builds request with relative path', () => { + const request = builder.build({ + path: '/people', + method: 'GET', + query: { search: 'test' }, + }); + + assertEquals(request.url, 'https://example.com/api/xm/1/people?search=test'); + assertEquals(request.path, '/people'); + assertEquals(request.method, 'GET'); + assertEquals(request.headers?.['default-header'], 'value'); + }); + + await t.step('builds request with external URL', () => { + const request = builder.build({ + fullUrl: 'https://api.external-service.com/v2/endpoint', + method: 'POST', + query: { key: 'value' }, + }); + + assertEquals(request.url, 'https://api.external-service.com/v2/endpoint?key=value'); + assertEquals(request.path, 'https://api.external-service.com/v2/endpoint'); + assertEquals(request.method, 'POST'); + assertEquals(request.headers?.['default-header'], 'value'); + }); + + await t.step('preserves query parameters in external URLs', () => { + const request = builder.build({ + fullUrl: 'https://api.external-service.com/search?existing=param', + query: { additional: 'param' }, + }); + + const url = new URL(request.url); + assertEquals(url.searchParams.get('existing'), 'param'); + assertEquals(url.searchParams.get('additional'), 'param'); + }); + + await t.step('throws when path does not start with slash', () => { + assertThrows( + () => builder.build({ path: 'people' }), + Error, + 'Path must start with a forward slash', + ); + }); + + await t.step('throws when both path and fullUrl are provided', () => { + assertThrows( + () => + builder.build({ + path: '/people', + fullUrl: 'https://api.external-service.com/v2/endpoint', + }), + Error, + 'Cannot specify both fullUrl and path', + ); + }); + + await t.step('throws when neither path nor fullUrl is provided', () => { + assertThrows( + () => builder.build({}), + Error, + 'Either path or fullUrl must be provided', + ); + }); +}); diff --git a/src/core/request-builder.ts b/src/core/request-builder.ts new file mode 100644 index 0000000..a97c8c7 --- /dev/null +++ b/src/core/request-builder.ts @@ -0,0 +1,74 @@ +import { HttpRequest } from './types.ts'; + +/** + * Request options for building HTTP requests. + * Either path or fullUrl must be provided, but not both. + */ +export interface RequestBuildOptions extends Partial { + /** + * The path relative to the API version path. + * Do not include the API version (/api/xm/1). + * Must start with a forward slash. + * @example "/people" + */ + path?: string; + + /** + * A complete URL to an external endpoint. + * Use this when you need to bypass the xMatters API completely. + * @example "https://api.external-service.com/v2/endpoint" + */ + fullUrl?: string; +} + +export class RequestBuilder { + private readonly apiVersionPath = '/api/xm/1'; + + constructor( + private readonly baseUrl: string, + private readonly defaultHeaders: Record = {}, + ) {} + + build(options: RequestBuildOptions): HttpRequest { + let url: URL; + + if (options.fullUrl && options.path) { + throw new Error( + 'Cannot specify both fullUrl and path. Use fullUrl for external endpoints, path for xMatters API endpoints.', + ); + } + + if (options.fullUrl) { + url = new URL(options.fullUrl); + } else if (options.path) { + if (!options.path.startsWith('/')) { + throw new Error('Path must start with a forward slash, e.g. "/people"'); + } + url = new URL(`${this.apiVersionPath}${options.path}`, this.baseUrl); + } else { + throw new Error('Either path or fullUrl must be provided'); + } + + // Add query parameters if present in the options + if (options.query) { + Object.entries(options.query).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)); + } + }); + } + + const builtRequest: HttpRequest = { + method: options.method || 'GET', + // For the path property, use the actual path provided or extract it from fullUrl + path: options.path || options.fullUrl || '', + url: url.toString(), + query: options.query, + headers: { ...this.defaultHeaders, ...options.headers }, + body: options.body, + retryAttempt: options.retryAttempt || 0, + }; + + return builtRequest; + } +} diff --git a/src/core/request-handler.test.ts b/src/core/request-handler.test.ts index 6f4543e..3a8b3d2 100644 --- a/src/core/request-handler.test.ts +++ b/src/core/request-handler.test.ts @@ -3,7 +3,8 @@ import { assertExists, assertRejects, } from 'https://deno.land/std@0.193.0/testing/asserts.ts'; -import { RequestBuilder, RequestHandler } from './request-handler.ts'; +import { RequestHandler } from './request-handler.ts'; +import { RequestBuilder } from './request-builder.ts'; import { HttpClient, HttpRequest, HttpResponse, Logger } from './types.ts'; import { XmApiError } from './errors.ts'; @@ -12,12 +13,14 @@ class TestHttpClient implements HttpClient { public responses: HttpResponse[] = []; public forceError?: Error; - async send(request: HttpRequest): Promise { + send(request: HttpRequest): Promise { this.requests.push(request); if (this.forceError) { - throw this.forceError; + return Promise.reject(this.forceError); } - return this.responses[this.requests.length - 1] || this.responses[this.responses.length - 1]; + return Promise.resolve( + this.responses[this.requests.length - 1] || this.responses[this.responses.length - 1], + ); } } diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index 71f2f96..4349419 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -9,40 +9,7 @@ import { } from './types.ts'; import { XmApiError } from './errors.ts'; import { TokenData, TokenState } from './oauth-types.ts'; - -export class RequestBuilder { - private readonly apiVersionPath = '/api/xm/1'; - - constructor( - private readonly baseUrl: string, - private readonly defaultHeaders: Record = {}, - ) {} - - build(request: Partial & { path: string }): HttpRequest { - const fullPath = `${this.apiVersionPath}${request.path}`; - const url = new URL(fullPath, this.baseUrl); - // Add query parameters if present in the request - if (request.query) { - Object.entries(request.query).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - url.searchParams.set(key, String(value)); - } - }); - } - - const builtRequest: HttpRequest = { - method: request.method || 'GET', - path: request.path, - url: url.toString(), - query: request.query, - headers: { ...this.defaultHeaders, ...request.headers }, - body: request.body, - retryAttempt: request.retryAttempt || 0, - }; - - return builtRequest; - } -} +import { RequestBuilder } from './request-builder.ts'; export class RequestHandler { /** Current token state if using OAuth */ @@ -197,7 +164,8 @@ export class RequestHandler { async send( request: Partial & { - path: string; + path?: string; + fullUrl?: string; method?: HttpRequest['method']; }, ): Promise> { diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts index ac050d8..57892f3 100644 --- a/src/core/test-utils.ts +++ b/src/core/test-utils.ts @@ -1,21 +1,23 @@ import { HttpClient, HttpRequest, HttpResponse, Logger, XmApiOptions } from './types.ts'; import { XmApiError } from './errors.ts'; -import { RequestBuilder, RequestHandler } from './request-handler.ts'; +import { RequestBuilder } from './request-builder.ts'; +import { RequestHandler } from './request-handler.ts'; class MockRequestBuilder extends RequestBuilder { constructor() { super('https://example.com'); } - override build(request: Partial & { path: string }): HttpRequest { + override build(options: { path?: string; fullUrl?: string } & Partial): HttpRequest { + const url = options.fullUrl || `https://example.com${options.path || '/'}`; return { - method: request.method || 'GET', - path: request.path, - url: `https://example.com${request.path}`, - query: request.query, - headers: request.headers || {}, - body: request.body, - retryAttempt: request.retryAttempt || 0, + method: options.method || 'GET', + path: options.path || options.fullUrl || '/', + url, + query: options.query, + headers: options.headers || {}, + body: options.body, + retryAttempt: options.retryAttempt || 0, }; } } @@ -28,7 +30,7 @@ export class MockRequestHandler extends RequestHandler { constructor(responses: HttpResponse | HttpResponse[]) { // Create minimal implementations for required dependencies const mockClient: HttpClient = { - send: async () => ({ status: 200, headers: {}, body: {} }), + send: () => Promise.resolve({ status: 200, headers: {}, body: {} }), }; const mockLogger: Logger = { debug: () => {}, diff --git a/src/index.ts b/src/index.ts index dfd8a13..f732f90 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ -import { RequestBuilder, RequestHandler } from './core/request-handler.ts'; +import { RequestBuilder } from './core/request-builder.ts'; +import { RequestHandler } from './core/request-handler.ts'; import { DefaultHttpClient, defaultLogger } from './core/defaults/index.ts'; import { XmApiOptions } from './core/types.ts'; import { GroupsEndpoint } from './endpoints/groups/index.ts'; From 72553cefeacc45435544c8250e39b7f71eb0dc37 Mon Sep 17 00:00:00 2001 From: johan Date: Fri, 6 Jun 2025 11:49:41 -0700 Subject: [PATCH 012/101] Refactor project structure FTW Gave some love to the types Add usefule doc for when zscaler zscales as usual... --- .vscode/extensions.json | 5 + README.md | 53 +++++- deno.json | 3 +- src/core/defaults/http-client.ts | 2 +- src/core/defaults/logger.ts | 2 +- src/core/request-builder.ts | 2 +- src/core/request-handler.test.ts | 3 +- src/core/request-handler.ts | 10 +- src/core/resource-client.ts | 7 +- src/core/test-utils.ts | 3 +- src/core/types.ts | 250 --------------------------- src/core/types/endpoint/composers.ts | 97 +++++++++++ src/core/types/endpoint/params.ts | 49 ++++++ src/core/types/endpoint/response.ts | 62 +++++++ src/core/types/internal/config.ts | 58 +++++++ src/core/types/internal/http.ts | 45 +++++ src/core/types/internal/methods.ts | 35 ++++ src/endpoints/groups/index.ts | 10 +- src/endpoints/groups/types.ts | 3 +- src/index.ts | 8 +- 20 files changed, 437 insertions(+), 270 deletions(-) create mode 100644 .vscode/extensions.json delete mode 100644 src/core/types.ts create mode 100644 src/core/types/endpoint/composers.ts create mode 100644 src/core/types/endpoint/params.ts create mode 100644 src/core/types/endpoint/response.ts create mode 100644 src/core/types/internal/config.ts create mode 100644 src/core/types/internal/http.ts create mode 100644 src/core/types/internal/methods.ts diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..09cf720 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "denoland.vscode-deno" + ] +} diff --git a/README.md b/README.md index 595cb76..0227f49 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,43 @@ `xmas` for short 🎄 +### Maintainers +> **Setup**: After cloning, run `deno install` to install and cache all dependencies. + +> **VS Code Users**: When you first open this project, VS Code will suggest installing the Deno extension. Accept this suggestion to get proper TypeScript support, formatting, and linting as configured in the [.vscode/settings.json](.vscode/settings.json) file. + +> **Corporate Networks**: If you're behind a corporate firewall (like Zscaler), you may encounter SSL certificate issues when downloading dependencies. Use the configured tasks instead of direct Deno commands: + +**Development Commands**: +- `deno task test` - Run all unit tests (handles corporate certificates) +- `deno task cache` - Cache dependencies (handles corporate certificates) +- `deno run --allow-net sandbox/index.ts` - Run sandbox for quick prototyping + +**Alternative Commands** (if not behind corporate firewall): +- `deno test` - Run all unit tests +- `deno cache --reload src/**/*.ts` - Cache dependencies + +### Troubleshooting + +**SSL Certificate Issues**: +If you encounter errors like `invalid peer certificate: UnknownIssuer` when running Deno commands, you're likely behind a corporate firewall that intercepts SSL certificates. + +**Solution**: Use the configured tasks which include `DENO_TLS_CA_STORE=system`: +```bash +deno task test # Instead of: deno test +deno task cache # Instead of: deno cache --reload src/**/*.ts +``` + +**Manual Override**: For any other Deno command, prefix with the environment variable: +```bash +DENO_TLS_CA_STORE=system deno [your-command] +``` + +**Permanent Fix**: Add this to your shell profile (`~/.zshrc`): +```bash +export DENO_TLS_CA_STORE=system +``` + # Usage If your project already relies on the `axios` library, `xmas` will just use it to send http requests @@ -22,9 +59,19 @@ const xmas = new Xmas(config); // Create a new group in your xMatters instance: const group = { targetName: 'API developers' }; -xmas.groups.create(group) - .then(handleSuccess) - .catch(handleError); +const response = await xmas.groups.save(group); + +// Access the HTTP response details: +console.log('Status:', response.status); +console.log('Headers:', response.headers); +console.log('Created group:', response.body); + +// Get groups with pagination: +const groupsResponse = await xmas.groups.getGroups({ limit: 10, offset: 0 }); +console.log('Total groups:', groupsResponse.body.total); +groupsResponse.body.data.forEach(group => { + console.log('Group:', group.targetName); +}); ``` Alternative `config` object for **OAuth** (say when you have already generated tokens and safely diff --git a/deno.json b/deno.json index 7fe8959..5e2c1eb 100644 --- a/deno.json +++ b/deno.json @@ -3,7 +3,8 @@ "std/": "https://deno.land/std@0.193.0/" }, "tasks": { - "test": "deno test" + "test": "DENO_TLS_CA_STORE=system deno test", + "cache": "DENO_TLS_CA_STORE=system deno cache --reload src/**/*.ts" }, "fmt": { "singleQuote": true, diff --git a/src/core/defaults/http-client.ts b/src/core/defaults/http-client.ts index 2f4065a..de6396e 100644 --- a/src/core/defaults/http-client.ts +++ b/src/core/defaults/http-client.ts @@ -1,4 +1,4 @@ -import { HttpClient, HttpRequest, HttpResponse } from '../types.ts'; +import { HttpClient, HttpRequest, HttpResponse } from '../types/internal/http.ts'; export class DefaultHttpClient implements HttpClient { async send(request: HttpRequest): Promise { diff --git a/src/core/defaults/logger.ts b/src/core/defaults/logger.ts index f17abb2..1707e81 100644 --- a/src/core/defaults/logger.ts +++ b/src/core/defaults/logger.ts @@ -1,4 +1,4 @@ -import { Logger } from '../types.ts'; +import { Logger } from '../types/internal/config.ts'; export const defaultLogger: Logger = { debug: console.debug.bind(console), diff --git a/src/core/request-builder.ts b/src/core/request-builder.ts index a97c8c7..a6528ab 100644 --- a/src/core/request-builder.ts +++ b/src/core/request-builder.ts @@ -1,4 +1,4 @@ -import { HttpRequest } from './types.ts'; +import { HttpRequest } from './types/internal/http.ts'; /** * Request options for building HTTP requests. diff --git a/src/core/request-handler.test.ts b/src/core/request-handler.test.ts index 3a8b3d2..4c4f482 100644 --- a/src/core/request-handler.test.ts +++ b/src/core/request-handler.test.ts @@ -5,7 +5,8 @@ import { } from 'https://deno.land/std@0.193.0/testing/asserts.ts'; import { RequestHandler } from './request-handler.ts'; import { RequestBuilder } from './request-builder.ts'; -import { HttpClient, HttpRequest, HttpResponse, Logger } from './types.ts'; +import { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; +import { Logger } from './types/internal/config.ts'; import { XmApiError } from './errors.ts'; class TestHttpClient implements HttpClient { diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index 4349419..8af0355 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -1,12 +1,14 @@ import { - DeleteOptions, - GetOptions, HttpClient, HttpRequest, HttpResponse, - Logger, +} from './types/internal/http.ts'; +import { Logger } from './types/internal/config.ts'; +import { + DeleteOptions, + GetOptions, RequestWithBodyOptions, -} from './types.ts'; +} from './types/internal/methods.ts'; import { XmApiError } from './errors.ts'; import { TokenData, TokenState } from './oauth-types.ts'; import { RequestBuilder } from './request-builder.ts'; diff --git a/src/core/resource-client.ts b/src/core/resource-client.ts index c40f445..f88caeb 100644 --- a/src/core/resource-client.ts +++ b/src/core/resource-client.ts @@ -1,4 +1,9 @@ -import type { DeleteOptions, GetOptions, HttpRequest, RequestWithBodyOptions } from './types.ts'; +import type { HttpRequest } from './types/internal/http.ts'; +import type { + DeleteOptions, + GetOptions, + RequestWithBodyOptions, +} from './types/internal/methods.ts'; import { RequestHandler } from './request-handler.ts'; /** diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts index 57892f3..1ffa6dd 100644 --- a/src/core/test-utils.ts +++ b/src/core/test-utils.ts @@ -1,4 +1,5 @@ -import { HttpClient, HttpRequest, HttpResponse, Logger, XmApiOptions } from './types.ts'; +import { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; +import { Logger, XmApiOptions } from './types/internal/config.ts'; import { XmApiError } from './errors.ts'; import { RequestBuilder } from './request-builder.ts'; import { RequestHandler } from './request-handler.ts'; diff --git a/src/core/types.ts b/src/core/types.ts deleted file mode 100644 index 0d1e7e3..0000000 --- a/src/core/types.ts +++ /dev/null @@ -1,250 +0,0 @@ -/** - * Represents an HTTP response from the xMatters API. - * @template T The expected type of the response body - */ -export interface HttpResponse { - /** The parsed response body */ - body: T; - /** The HTTP status code */ - status: number; - /** Response headers */ - headers: Record; -} - -/** - * Represents an HTTP request to be sent to the xMatters API. - */ -export interface HttpRequest { - /** The HTTP method to use for the request */ - method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; - /** The complete URL for the request */ - url: string; - /** The path portion of the URL, used for testing and debugging */ - path: string; - /** Optional headers to send with the request */ - headers?: Record; - /** Optional query parameters to include in the URL */ - query?: Record; - /** Optional request body */ - body?: unknown; - /** Used internally for retry logic */ - retryAttempt?: number; -} - -export interface HttpClient { - send: (request: HttpRequest) => Promise; -} - -export interface Logger { - debug: (message: string, ...args: unknown[]) => void; - info: (message: string, ...args: unknown[]) => void; - warn: (message: string, ...args: unknown[]) => void; - error: (message: string, ...args: unknown[]) => void; -} - -export interface XmApiBaseOptions { - hostname: string; - httpClient?: HttpClient; - logger?: Logger; - defaultHeaders?: Record; - maxRetries?: number; -} - -export interface XmApiBasicAuthOptions extends XmApiBaseOptions { - username: string; - password: string; -} - -export interface XmApiOAuthOptions extends XmApiBaseOptions { - accessToken: string; - refreshToken?: string; - clientId?: string; - onTokenRefresh?: (accessToken: string, refreshToken: string) => void | Promise; -} - -export type XmApiOptions = XmApiBasicAuthOptions | XmApiOAuthOptions; - -export function isOAuthOptions(options: XmApiOptions): options is XmApiOAuthOptions { - return 'accessToken' in options; -} - -/** - * Base interface for all HTTP method options - */ -export interface HttpMethodOptions { - /** The path portion of the URL, relative to the API version path */ - path: string; - /** Optional headers to send with the request */ - headers?: Record; -} - -/** - * Options for GET requests - */ -export interface GetOptions extends HttpMethodOptions { - /** Optional query parameters */ - query?: Record; -} - -/** - * Options for POST, PUT, and PATCH requests - */ -export interface RequestWithBodyOptions extends HttpMethodOptions { - /** The request body */ - body?: unknown; -} - -/** - * Options for DELETE requests - */ -export type DeleteOptions = HttpMethodOptions; - -/** - * Common pagination parameters used across many endpoints - */ -export interface PaginationParams { - /** - * The maximum number of records to return - * @default 100 - */ - limit?: number; - - /** - * The number of records to skip - * Used for pagination in combination with limit - * @default 0 - */ - offset?: number; -} - -/** - * Common search parameters used across many endpoints - */ -export interface SearchParams { - /** - * A string used to filter records by matching on names or other searchable fields - * The search is typically case-insensitive and matches any part of the searchable fields - */ - search?: string; -} - -/** - * Common sorting parameters used across many endpoints - */ -export interface SortParams { - /** - * Field to sort by - */ - sortBy?: string; - - /** - * Sort direction - * @default 'ASC' - */ - sortOrder?: 'ASC' | 'DESC'; -} - -/** - * Helper type to add pagination to endpoint parameters - */ -export type WithPagination = Record> = - & T - & PaginationParams; - -/** - * Helper type to add search capability to endpoint parameters - */ -export type WithSearch = Record> = - & T - & SearchParams; - -/** - * Helper type to add sorting to endpoint parameters - */ -export type WithSort = Record> = T & SortParams; - -/** - * Common response wrapper for paginated lists - */ -export interface PaginatedResponse { - /** Number of items in this response */ - count: number; - /** Total number of items available */ - total: number; - /** The items for this page */ - data: T[]; - /** HAL links for navigation */ - links?: { - /** URL to current page */ - self: string; - /** URL to next page, if available */ - next?: string; - /** URL to previous page, if available */ - prev?: string; - }; -} - -/** - * Common type utilities for building endpoint parameter types. - * - * @example Simple paginated endpoint - * ```typescript - * interface DeviceFilters extends Record { - * status?: 'ACTIVE' | 'INACTIVE'; - * } - * - * type GetDevicesParams = WithPagination; - * // Results in: - * // { - * // status?: 'ACTIVE' | 'INACTIVE'; - * // limit?: number; - * // offset?: number; - * // } - * ``` - * - * @example Endpoint with search and pagination - * ```typescript - * interface UserFilters extends Record { - * role?: string; - * } - * - * // Compose multiple parameter types - * type GetUsersParams = WithPagination>; - * // Results in: - * // { - * // role?: string; - * // search?: string; - * // limit?: number; - * // offset?: number; - * // } - * ``` - * - * @example Full endpoint type definition - * ```typescript - * // 1. Define your resource type - * interface User { - * id: string; - * name: string; - * // ...other properties - * } - * - * // 2. Define endpoint-specific filters - * interface UserFilters extends Record { - * role?: string; - * status?: 'ACTIVE' | 'INACTIVE'; - * } - * - * // 3. Compose parameter types with pagination, search, and sort - * type GetUsersParams = WithPagination>>; - * - * // 4. Use the generic paginated response - * type GetUsersResponse = PaginatedResponse; - * - * // Now you can implement your endpoint: - * class UsersEndpoint { - * async getUsers(params?: GetUsersParams): Promise { - * return this.http.get({ path: '/users', query: params }); - * } - * } - * ``` - */ diff --git a/src/core/types/endpoint/composers.ts b/src/core/types/endpoint/composers.ts new file mode 100644 index 0000000..afd903f --- /dev/null +++ b/src/core/types/endpoint/composers.ts @@ -0,0 +1,97 @@ +/** + * Type composers for composing endpoint parameter types. + * These utilities make it easy to build complex parameter types by composing simpler ones. + */ + +import type { PaginationParams, SearchParams, SortParams } from './params.ts'; + +/** + * Helper type to add pagination to endpoint parameters + */ +export type WithPagination = Record> = + & T + & PaginationParams; + +/** + * Helper type to add search capability to endpoint parameters + */ +export type WithSearch = Record> = + & T + & SearchParams; + +/** + * Helper type to add sorting to endpoint parameters + */ +export type WithSort = Record> = T & SortParams; + +/** + * Common type utilities for composing endpoint parameter types. + * + * @example Simple paginated endpoint + * ```typescript + * interface DeviceFilters extends Record { + * status?: 'ACTIVE' | 'INACTIVE'; + * } + * + * type GetDevicesParams = WithPagination; + * // Results in: + * // { + * // status?: 'ACTIVE' | 'INACTIVE'; + * // limit?: number; + * // offset?: number; + * // } + * ``` + * + * @example Endpoint with search and pagination + * ```typescript + * interface UserFilters extends Record { + * role?: string; + * } + * + * // Compose multiple parameter types + * type GetUsersParams = WithPagination>; + * // Results in: + * // { + * // role?: string; + * // search?: string; + * // limit?: number; + * // offset?: number; + * // } + * ``` + * + * @example Full endpoint type definition + * ```typescript + * // 1. Define your resource type + * interface User { + * id: string; + * name: string; + * // ...other properties + * } + * + * // 2. Define endpoint-specific filters + * interface UserFilters extends Record { + * role?: string; + * status?: 'ACTIVE' | 'INACTIVE'; + * } + * + * // 3. Compose parameter types with pagination, search, and sort + * type GetUsersParams = WithPagination>>; + * + * // 4. Use the HTTP response wrapper types for return types + * // For paginated responses: + * type GetUsersResponse = PaginatedHttpResponse; + * // For single resource responses: + * type GetUserResponse = ResourceHttpResponse; + * + * // Now you can implement your endpoint: + * class UsersEndpoint { + * async getUsers(params?: GetUsersParams): GetUsersResponse { + * return this.http.get({ path: '/users', query: params }); + * } + * + * async getById(id: string): GetUserResponse { + * return this.http.get({ path: `/${id}` }); + * } + * } + * ``` + */ diff --git a/src/core/types/endpoint/params.ts b/src/core/types/endpoint/params.ts new file mode 100644 index 0000000..8fab42c --- /dev/null +++ b/src/core/types/endpoint/params.ts @@ -0,0 +1,49 @@ +/** + * Common parameter types for endpoint implementations. + * These provide standardized parameter shapes that endpoints can use and compose. + */ + +/** + * Common pagination parameters used across many endpoints + */ +export interface PaginationParams { + /** + * The maximum number of records to return + * @default 100 + */ + limit?: number; + + /** + * The number of records to skip + * Used for pagination in combination with limit + * @default 0 + */ + offset?: number; +} + +/** + * Common search parameters used across many endpoints + */ +export interface SearchParams { + /** + * A string used to filter records by matching on names or other searchable fields + * The search is typically case-insensitive and matches any part of the searchable fields + */ + search?: string; +} + +/** + * Common sorting parameters used across many endpoints + */ +export interface SortParams { + /** + * Field to sort by + */ + sortBy?: string; + + /** + * Sort direction + * @default 'ASC' + */ + sortOrder?: 'ASC' | 'DESC'; +} diff --git a/src/core/types/endpoint/response.ts b/src/core/types/endpoint/response.ts new file mode 100644 index 0000000..eb02e42 --- /dev/null +++ b/src/core/types/endpoint/response.ts @@ -0,0 +1,62 @@ +/** + * Response wrapper types for endpoint implementations. + * These provide standardized response shapes that endpoints can use. + */ + +import type { HttpResponse } from '../internal/http.ts'; + +/** + * Common response wrapper for paginated lists + */ +export interface PaginatedResponse { + /** Number of items in this response */ + count: number; + /** Total number of items available */ + total: number; + /** The items for this page */ + data: T[]; + /** HAL links for navigation */ + links?: { + /** URL to current page */ + self: string; + /** URL to next page, if available */ + next?: string; + /** URL to previous page, if available */ + prev?: string; + }; +} + +/** + * Type alias for HTTP responses containing paginated data. + * Use this for endpoint methods that return paginated lists. + * + * @template T The type of items in the paginated response + * + * @example + * ```typescript + * // Instead of: Promise> + * // Use: Promise> + * getGroups(): Promise> { + * return this.http.get>({ path: '/groups' }); + * } + * ``` + */ +export type PaginatedHttpResponse = HttpResponse>; + +// Note: For single resource responses, use HttpResponse directly +// Example: Promise> instead of creating an unnecessary alias + +/** + * Type alias for HTTP responses that don't return a body (like delete operations). + * Use this for endpoint methods that perform actions without returning data. + * + * @example + * ```typescript + * // Instead of: Promise + * // Use: Promise + * delete(id: string): Promise { + * return this.http.delete({ path: `/${id}` }); + * } + * ``` + */ +export type EmptyHttpResponse = HttpResponse; diff --git a/src/core/types/internal/config.ts b/src/core/types/internal/config.ts new file mode 100644 index 0000000..642cafa --- /dev/null +++ b/src/core/types/internal/config.ts @@ -0,0 +1,58 @@ +/** + * Configuration and logging types used internally by the library. + * These types define how the library is configured and how it handles logging. + */ + +import type { HttpClient } from './http.ts'; + +/** + * Interface that loggers must implement to be used with this library. + * This allows consumers to inject their own logging implementation. + */ +export interface Logger { + debug: (message: string, ...args: unknown[]) => void; + info: (message: string, ...args: unknown[]) => void; + warn: (message: string, ...args: unknown[]) => void; + error: (message: string, ...args: unknown[]) => void; +} + +/** + * Base configuration options shared by all authentication methods. + */ +export interface XmApiBaseOptions { + hostname: string; + httpClient?: HttpClient; + logger?: Logger; + defaultHeaders?: Record; + maxRetries?: number; +} + +/** + * Configuration options for basic authentication. + */ +export interface XmApiBasicAuthOptions extends XmApiBaseOptions { + username: string; + password: string; +} + +/** + * Configuration options for OAuth authentication. + */ +export interface XmApiOAuthOptions extends XmApiBaseOptions { + accessToken: string; + refreshToken?: string; + clientId?: string; + onTokenRefresh?: (accessToken: string, refreshToken: string) => void | Promise; +} + +/** + * Union type of all possible configuration options. + */ +export type XmApiOptions = XmApiBasicAuthOptions | XmApiOAuthOptions; + +/** + * Type guard to determine if options are for OAuth authentication. + */ +export function isOAuthOptions(options: XmApiOptions): options is XmApiOAuthOptions { + return 'accessToken' in options; +} diff --git a/src/core/types/internal/http.ts b/src/core/types/internal/http.ts new file mode 100644 index 0000000..9d737a3 --- /dev/null +++ b/src/core/types/internal/http.ts @@ -0,0 +1,45 @@ +/** + * Core HTTP types used internally by the library. + * These types define the shape of requests and responses handled by the HTTP layer. + */ + +/** + * Represents an HTTP response from the xMatters API. + * @template T The expected type of the response body + */ +export interface HttpResponse { + /** The parsed response body */ + body: T; + /** The HTTP status code */ + status: number; + /** Response headers */ + headers: Record; +} + +/** + * Represents an HTTP request to be sent to the xMatters API. + */ +export interface HttpRequest { + /** The HTTP method to use for the request */ + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + /** The complete URL for the request */ + url: string; + /** The path portion of the URL, used for testing and debugging */ + path: string; + /** Optional headers to send with the request */ + headers?: Record; + /** Optional query parameters to include in the URL */ + query?: Record; + /** Optional request body */ + body?: unknown; + /** Used internally for retry logic */ + retryAttempt?: number; +} + +/** + * Interface that HTTP clients must implement to be used with this library. + * This allows consumers to inject their own HTTP implementation. + */ +export interface HttpClient { + send: (request: HttpRequest) => Promise; +} diff --git a/src/core/types/internal/methods.ts b/src/core/types/internal/methods.ts new file mode 100644 index 0000000..ecaa4c4 --- /dev/null +++ b/src/core/types/internal/methods.ts @@ -0,0 +1,35 @@ +/** + * HTTP method option types used internally by the request handling layer. + * These types define the shape of options passed to HTTP method calls. + */ + +/** + * Base interface for all HTTP method options + */ +export interface HttpMethodOptions { + /** The path portion of the URL, relative to the API version path */ + path: string; + /** Optional headers to send with the request */ + headers?: Record; +} + +/** + * Options for GET requests + */ +export interface GetOptions extends HttpMethodOptions { + /** Optional query parameters */ + query?: Record; +} + +/** + * Options for POST, PUT, and PATCH requests + */ +export interface RequestWithBodyOptions extends HttpMethodOptions { + /** The request body */ + body?: unknown; +} + +/** + * Options for DELETE requests + */ +export type DeleteOptions = HttpMethodOptions; diff --git a/src/endpoints/groups/index.ts b/src/endpoints/groups/index.ts index 4eeecf5..cff6e4c 100644 --- a/src/endpoints/groups/index.ts +++ b/src/endpoints/groups/index.ts @@ -1,6 +1,10 @@ import { ResourceClient } from '../../core/resource-client.ts'; import { RequestHandler } from '../../core/request-handler.ts'; -import type { HttpResponse } from '../../core/types.ts'; +import type { HttpResponse } from '../../core/types/internal/http.ts'; +import type { + EmptyHttpResponse, + PaginatedHttpResponse, +} from '../../core/types/endpoint/response.ts'; import { GetGroupsParams, GetGroupsResponse, Group } from './types.ts'; /** @@ -44,7 +48,7 @@ export class GroupsEndpoint { * @returns The HTTP response containing a paginated list of groups * @throws {XmApiError} If the request fails */ - getGroups(params?: GetGroupsParams): Promise> { + getGroups(params?: GetGroupsParams): Promise> { return this.http.get({ query: params }); } @@ -77,7 +81,7 @@ export class GroupsEndpoint { * @returns The HTTP response * @throws {XmApiError} If the request fails */ - delete(id: string): Promise> { + delete(id: string): Promise { return this.http.delete({ path: id }); } } diff --git a/src/endpoints/groups/types.ts b/src/endpoints/groups/types.ts index 9508b7e..a5b1f2e 100644 --- a/src/endpoints/groups/types.ts +++ b/src/endpoints/groups/types.ts @@ -1,4 +1,5 @@ -import { PaginatedResponse, WithPagination, WithSearch } from '../../core/types.ts'; +import { PaginatedResponse } from '../../core/types/endpoint/response.ts'; +import { WithPagination, WithSearch } from '../../core/types/endpoint/composers.ts'; /** * Represents a group in xMatters. diff --git a/src/index.ts b/src/index.ts index f732f90..caad9aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { RequestBuilder } from './core/request-builder.ts'; import { RequestHandler } from './core/request-handler.ts'; import { DefaultHttpClient, defaultLogger } from './core/defaults/index.ts'; -import { XmApiOptions } from './core/types.ts'; +import { XmApiOptions } from './core/types/internal/config.ts'; import { GroupsEndpoint } from './endpoints/groups/index.ts'; /** @@ -91,5 +91,9 @@ export class XmApi { } // Re-export types -export * from './core/types.ts'; +export * from './core/types/internal/config.ts'; +export * from './core/types/internal/http.ts'; +export * from './core/types/endpoint/response.ts'; +export * from './core/types/endpoint/composers.ts'; +export * from './core/types/endpoint/params.ts'; export * from './endpoints/groups/types.ts'; From b4588e15b034932ae231565bfb2a33d237a3c6b6 Mon Sep 17 00:00:00 2001 From: johan Date: Fri, 6 Jun 2025 12:29:23 -0700 Subject: [PATCH 013/101] Get started on endpoint implementation patterns documentation --- docs/endpoint-patterns.md | 266 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 docs/endpoint-patterns.md diff --git a/docs/endpoint-patterns.md b/docs/endpoint-patterns.md new file mode 100644 index 0000000..163a4e6 --- /dev/null +++ b/docs/endpoint-patterns.md @@ -0,0 +1,266 @@ +# Endpoint Implementation Patterns + +This guide shows the recommended patterns for implementing new endpoints in the xMatters API library. + +## Directory Structure + +Each endpoint should be implemented in its own directory under `src/endpoints/`: + +``` +src/endpoints/my-endpoint/ +├── index.ts # Endpoint implementation +├── types.ts # Type definitions +└── index.test.ts # Unit tests +``` + +## Type Definitions (`types.ts`) + +Follow this pattern for defining endpoint types: + +```typescript +import { PaginatedResponse } from '../../core/types/endpoint/response.ts'; +import { WithPagination, WithSearch, WithSort } from '../../core/types/endpoint/composers.ts'; + +/** + * 1. Define your main resource type + */ +export interface MyResource { + id: string; + name: string; + status: 'ACTIVE' | 'INACTIVE'; + created: string; + // ...other properties +} + +/** + * 2. Define endpoint-specific filters (if needed) + * Filters are query parameters that narrow down results beyond pagination and search. + * Only add if your endpoint supports filtering by specific field values. + */ +export interface MyResourceFilters extends Record { + status?: 'ACTIVE' | 'INACTIVE'; + category?: string; +} + +/** + * 3. Compose parameter types using composers + */ +export type GetMyResourcesParams = WithPagination>>; + +/** + * 4. Define response body types for maintainers to use (if using paginated responses) + * This represents what the API returns. Use as the generic type parameter for this.http.get. + * Not to be confused with the consumer-facing method return type + * which should be Promise>. + */ +export type GetMyResourcesResponse = PaginatedResponse; +``` + +## Endpoint Implementation (`index.ts`) + +Follow this pattern for implementing the endpoint class: + +```typescript +import { ResourceClient } from '../../core/resource-client.ts'; +import { RequestHandler } from '../../core/request-handler.ts'; +import type { HttpResponse } from '../../core/types/internal/http.ts'; +import type { + EmptyHttpResponse, + PaginatedHttpResponse, + PaginatedResponse, +} from '../../core/types/endpoint/response.ts'; +import { GetMyResourcesParams, GetMyResourcesResponse, MyResource } from './types.ts'; + +/** + * Provides access to the my-resources endpoints of the xMatters API. + * Use this class to manage resources, including listing, creating, updating, and deleting. + * + * @example + * ```typescript + * const xm = new XmApi({ + * hostname: 'https://example.xmatters.com', + * accessToken: 'your-token' + * }); + * + * // Get all resources + * const { body: resources } = await xm.myResources.getMyResources(); + * + * // Get resources with pagination + * const { body: pagedResources } = await xm.myResources.getMyResources({ + * limit: 10, + * offset: 0 + * }); + * + * // Search for resources + * const { body: searchedResources } = await xm.myResources.getMyResources({ + * search: 'keyword' + * }); + * ``` + */ +export class MyResourcesEndpoint { + private readonly http: ResourceClient; + + constructor(http: RequestHandler) { + // The base path will be automatically prepended to all requests + this.http = new ResourceClient(http, '/my-resources'); + } + + /** + * Get a list of resources from xMatters. + * The results can be filtered and paginated using the params object. + * + * @param params Optional parameters to filter and paginate the results + * @returns The HTTP response containing a paginated list of resources + * @throws {XmApiError} If the request fails + */ + getMyResources(params?: GetMyResourcesParams): Promise> { + return this.http.get({ query: params }); + } + + /** + * Get a resource by ID + * + * @param id The ID of the resource to retrieve + * @returns The HTTP response containing the resource + * @throws {XmApiError} If the request fails + */ + getById(id: string): Promise> { + return this.http.get({ path: id }); + } + + /** + * Create a new resource or update an existing one + * + * @param resource The resource data to create or update + * @returns The HTTP response containing the created or updated resource + * @throws {XmApiError} If the request fails + */ + save(resource: Partial): Promise> { + return this.http.post({ body: resource }); + } + + /** + * Delete a resource by ID + * + * @param id The ID of the resource to delete + * @returns The HTTP response + * @throws {XmApiError} If the request fails + */ + delete(id: string): Promise { + return this.http.delete({ path: id }); + } +} +``` + +## Adding to Main API Class + +Don't forget to add your new endpoint to the main `XmApi` class: + +```typescript +// In src/index.ts +import { MyResourcesEndpoint } from './endpoints/my-resources/index.ts'; + +export class XmApi { + /** HTTP handler that manages all API requests */ + private readonly http: RequestHandler; + + /** Access groups-related endpoints */ + public readonly groups: GroupsEndpoint; + + /** Access my-resources-related endpoints */ + public readonly myResources: MyResourcesEndpoint; + + constructor(options: XmApiOptions) { + // ...existing code... + + // Initialize endpoints + this.groups = new GroupsEndpoint(this.http); + this.myResources = new MyResourcesEndpoint(this.http); // Add this line + } +} + +// Also export the types +export * from './endpoints/my-resources/types.ts'; +``` + +## Key Benefits of This Pattern + +1. **Consistent Return Types**: All methods return `Promise>` for predictable handling +2. **Type Safety**: Full TypeScript support with proper generics +3. **Reusable Components**: Use type composers for common patterns (pagination, search, etc.) +4. **Easy Testing**: MockRequestHandler provides consistent testing patterns +5. **Automatic Path Management**: ResourceClient handles base path automatically +6. **Comprehensive Documentation**: Clear JSDoc comments for all methods +7. **Zero Dependencies**: Uses only Deno standard library and internal utilities + +## Response Type Guidelines + +- **Paginated responses**: Use `PaginatedHttpResponse` - provides semantic meaning for lists +- **Single resources**: Use `HttpResponse` directly - clear and explicit +- **Empty responses**: Use `EmptyHttpResponse` - adds semantic meaning for void operations + +## Response Type Examples + +### For Consumers + +```typescript +// Get groups with full HTTP response access +const response = await xm.groups.getGroups({ limit: 10 }); + +// Access response metadata +console.log('Status:', response.status); +console.log('Headers:', response.headers); + +// Access response body +console.log('Total groups:', response.body.total); +response.body.data.forEach(group => { + console.log('Group:', group.targetName); +}); +``` + +### For Error Handling + +```typescript +try { + const response = await xm.groups.getGroups(); + // Handle success +} catch (error) { + if (error instanceof XmApiError) { + console.log('Error message:', error.message); + if (error.response) { + console.log('HTTP status:', error.response.status); + console.log('Response body:', error.response.body); + } + } +} +``` + +## Testing Patterns + +The library uses `MockRequestHandler` for consistent testing: + +```typescript +// Create mock response +const mockResponse = createMockResponse({ + body: { /* your response data */ }, + status: 200, + headers: { 'content-type': 'application/json' } +}); + +// Create mock HTTP handler +const mockHttp = new MockRequestHandler(mockResponse); +const endpoint = new MyResourcesEndpoint(mockHttp); + +// Call endpoint method +const response = await endpoint.getMyResources(); + +// Assert on response +assertEquals(response.body, expectedBody); + +// Assert on request that was made +const request = mockHttp.requests[0]; +assertEquals(request.method, 'GET'); +assertEquals(request.path, '/my-resources'); +``` + +This pattern ensures consistency across all endpoints while maintaining flexibility for consumers to access both response data and HTTP metadata when needed. From 99866ad32bd37ab857bea3b05e94a78d828eccfd Mon Sep 17 00:00:00 2001 From: johan Date: Fri, 6 Jun 2025 14:38:14 -0700 Subject: [PATCH 014/101] Continuing with organizing the types and preparing to implement oauth functionality --- .github/copilot-instructions.md | 6 +++ README.md | 21 +++++++-- docs/endpoint-patterns.md | 29 +++++++----- src/core/request-handler.ts | 14 ++---- src/core/types/internal/config.ts | 11 ++++- .../internal/oauth.ts} | 5 ++ src/index.ts | 47 +++++++++++++++---- 7 files changed, 93 insertions(+), 40 deletions(-) rename src/core/{oauth-types.ts => types/internal/oauth.ts} (88%) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0c684ad..8408c78 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -204,3 +204,9 @@ Features: Do not make any changes until you have 95% confidence that you know what to build. Ask me follow up questions until you have that confidence. + +To run unit tests, always prompt the user to run the following command: + +```bash +deno test +``` diff --git a/README.md b/README.md index 0227f49..3815d15 100644 --- a/README.md +++ b/README.md @@ -3,38 +3,49 @@ `xmas` for short 🎄 ### Maintainers + > **Setup**: After cloning, run `deno install` to install and cache all dependencies. -> **VS Code Users**: When you first open this project, VS Code will suggest installing the Deno extension. Accept this suggestion to get proper TypeScript support, formatting, and linting as configured in the [.vscode/settings.json](.vscode/settings.json) file. +> **VS Code Users**: When you first open this project, VS Code will suggest installing the Deno +> extension. Accept this suggestion to get proper TypeScript support, formatting, and linting as +> configured in the [.vscode/settings.json](.vscode/settings.json) file. -> **Corporate Networks**: If you're behind a corporate firewall (like Zscaler), you may encounter SSL certificate issues when downloading dependencies. Use the configured tasks instead of direct Deno commands: +> **Corporate Networks**: If you're behind a corporate firewall (like Zscaler), you may encounter +> SSL certificate issues when downloading dependencies. Use the configured tasks instead of direct +> Deno commands: **Development Commands**: + - `deno task test` - Run all unit tests (handles corporate certificates) - `deno task cache` - Cache dependencies (handles corporate certificates) - `deno run --allow-net sandbox/index.ts` - Run sandbox for quick prototyping **Alternative Commands** (if not behind corporate firewall): + - `deno test` - Run all unit tests - `deno cache --reload src/**/*.ts` - Cache dependencies ### Troubleshooting -**SSL Certificate Issues**: -If you encounter errors like `invalid peer certificate: UnknownIssuer` when running Deno commands, you're likely behind a corporate firewall that intercepts SSL certificates. +**SSL Certificate Issues**: If you encounter errors like `invalid peer certificate: UnknownIssuer` +when running Deno commands, you're likely behind a corporate firewall that intercepts SSL +certificates. **Solution**: Use the configured tasks which include `DENO_TLS_CA_STORE=system`: + ```bash deno task test # Instead of: deno test deno task cache # Instead of: deno cache --reload src/**/*.ts ``` **Manual Override**: For any other Deno command, prefix with the environment variable: + ```bash DENO_TLS_CA_STORE=system deno [your-command] ``` **Permanent Fix**: Add this to your shell profile (`~/.zshrc`): + ```bash export DENO_TLS_CA_STORE=system ``` @@ -69,7 +80,7 @@ console.log('Created group:', response.body); // Get groups with pagination: const groupsResponse = await xmas.groups.getGroups({ limit: 10, offset: 0 }); console.log('Total groups:', groupsResponse.body.total); -groupsResponse.body.data.forEach(group => { +groupsResponse.body.data.forEach((group) => { console.log('Group:', group.targetName); }); ``` diff --git a/docs/endpoint-patterns.md b/docs/endpoint-patterns.md index 163a4e6..30f3ff0 100644 --- a/docs/endpoint-patterns.md +++ b/docs/endpoint-patterns.md @@ -1,6 +1,7 @@ # Endpoint Implementation Patterns -This guide shows the recommended patterns for implementing new endpoints in the xMatters API library. +This guide shows the recommended patterns for implementing new endpoints in the xMatters API +library. ## Directory Structure @@ -60,7 +61,7 @@ export type GetMyResourcesResponse = PaginatedResponse; Follow this pattern for implementing the endpoint class: -```typescript +````typescript import { ResourceClient } from '../../core/resource-client.ts'; import { RequestHandler } from '../../core/request-handler.ts'; import type { HttpResponse } from '../../core/types/internal/http.ts'; @@ -74,23 +75,23 @@ import { GetMyResourcesParams, GetMyResourcesResponse, MyResource } from './type /** * Provides access to the my-resources endpoints of the xMatters API. * Use this class to manage resources, including listing, creating, updating, and deleting. - * + * * @example * ```typescript * const xm = new XmApi({ * hostname: 'https://example.xmatters.com', * accessToken: 'your-token' * }); - * + * * // Get all resources * const { body: resources } = await xm.myResources.getMyResources(); - * + * * // Get resources with pagination * const { body: pagedResources } = await xm.myResources.getMyResources({ * limit: 10, * offset: 0 * }); - * + * * // Search for resources * const { body: searchedResources } = await xm.myResources.getMyResources({ * search: 'keyword' @@ -150,7 +151,7 @@ export class MyResourcesEndpoint { return this.http.delete({ path: id }); } } -``` +```` ## Adding to Main API Class @@ -166,7 +167,7 @@ export class XmApi { /** Access groups-related endpoints */ public readonly groups: GroupsEndpoint; - + /** Access my-resources-related endpoints */ public readonly myResources: MyResourcesEndpoint; @@ -185,7 +186,8 @@ export * from './endpoints/my-resources/types.ts'; ## Key Benefits of This Pattern -1. **Consistent Return Types**: All methods return `Promise>` for predictable handling +1. **Consistent Return Types**: All methods return `Promise>` for predictable + handling 2. **Type Safety**: Full TypeScript support with proper generics 3. **Reusable Components**: Use type composers for common patterns (pagination, search, etc.) 4. **Easy Testing**: MockRequestHandler provides consistent testing patterns @@ -213,7 +215,7 @@ console.log('Headers:', response.headers); // Access response body console.log('Total groups:', response.body.total); -response.body.data.forEach(group => { +response.body.data.forEach((group) => { console.log('Group:', group.targetName); }); ``` @@ -242,9 +244,9 @@ The library uses `MockRequestHandler` for consistent testing: ```typescript // Create mock response const mockResponse = createMockResponse({ - body: { /* your response data */ }, + body: {/* your response data */}, status: 200, - headers: { 'content-type': 'application/json' } + headers: { 'content-type': 'application/json' }, }); // Create mock HTTP handler @@ -263,4 +265,5 @@ assertEquals(request.method, 'GET'); assertEquals(request.path, '/my-resources'); ``` -This pattern ensures consistency across all endpoints while maintaining flexibility for consumers to access both response data and HTTP metadata when needed. +This pattern ensures consistency across all endpoints while maintaining flexibility for consumers to +access both response data and HTTP metadata when needed. diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index 8af0355..0783735 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -1,16 +1,8 @@ -import { - HttpClient, - HttpRequest, - HttpResponse, -} from './types/internal/http.ts'; +import { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; import { Logger } from './types/internal/config.ts'; -import { - DeleteOptions, - GetOptions, - RequestWithBodyOptions, -} from './types/internal/methods.ts'; +import { DeleteOptions, GetOptions, RequestWithBodyOptions } from './types/internal/methods.ts'; import { XmApiError } from './errors.ts'; -import { TokenData, TokenState } from './oauth-types.ts'; +import { TokenData, TokenState } from './types/internal/oauth.ts'; import { RequestBuilder } from './request-builder.ts'; export class RequestHandler { diff --git a/src/core/types/internal/config.ts b/src/core/types/internal/config.ts index 642cafa..c53015a 100644 --- a/src/core/types/internal/config.ts +++ b/src/core/types/internal/config.ts @@ -36,7 +36,7 @@ export interface XmApiBasicAuthOptions extends XmApiBaseOptions { } /** - * Configuration options for OAuth authentication. + * Configuration options for OAuth authentication with existing tokens. */ export interface XmApiOAuthOptions extends XmApiBaseOptions { accessToken: string; @@ -51,8 +51,15 @@ export interface XmApiOAuthOptions extends XmApiBaseOptions { export type XmApiOptions = XmApiBasicAuthOptions | XmApiOAuthOptions; /** - * Type guard to determine if options are for OAuth authentication. + * Type guard to determine if options are for OAuth authentication with existing tokens. */ export function isOAuthOptions(options: XmApiOptions): options is XmApiOAuthOptions { return 'accessToken' in options; } + +/** + * Type guard to determine if options are for basic authentication. + */ +export function isBasicAuthOptions(options: XmApiOptions): options is XmApiBasicAuthOptions { + return 'username' in options && 'password' in options; +} diff --git a/src/core/oauth-types.ts b/src/core/types/internal/oauth.ts similarity index 88% rename from src/core/oauth-types.ts rename to src/core/types/internal/oauth.ts index 4ba0d86..d12e945 100644 --- a/src/core/oauth-types.ts +++ b/src/core/types/internal/oauth.ts @@ -1,3 +1,8 @@ +/** + * OAuth2-related types used internally by the library. + * These types handle OAuth2 token responses, state management, and authentication flows. + */ + /** * Response from the OAuth2 token endpoint. */ diff --git a/src/index.ts b/src/index.ts index caad9aa..3dea0ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,9 @@ import { RequestBuilder } from './core/request-builder.ts'; import { RequestHandler } from './core/request-handler.ts'; import { DefaultHttpClient, defaultLogger } from './core/defaults/index.ts'; -import { XmApiOptions } from './core/types/internal/config.ts'; +import { isBasicAuthOptions, isOAuthOptions, XmApiOptions } from './core/types/internal/config.ts'; import { GroupsEndpoint } from './endpoints/groups/index.ts'; +import { TokenData } from './core/types/internal/oauth.ts'; /** * Main entry point for the xMatters API client. @@ -22,7 +23,7 @@ import { GroupsEndpoint } from './endpoints/groups/index.ts'; * }); * ``` * - * @example OAuth Authentication + * @example OAuth Authentication (with existing tokens) * ```typescript * const xm = new XmApi({ * hostname: 'https://example.xmatters.com', @@ -50,14 +51,17 @@ export class XmApi { * Creates the authorization header value based on the authentication type */ private createAuthorizationHeader(options: XmApiOptions): string { - if ('accessToken' in options) { + if (isOAuthOptions(options)) { return `Bearer ${options.accessToken}`; - } else { + } else if (isBasicAuthOptions(options)) { // In Deno, we use TextEncoder for proper UTF-8 encoding const encoder = new TextEncoder(); const authString = `${options.username}:${options.password}`; const auth = btoa(String.fromCharCode(...encoder.encode(authString))); return `Basic ${auth}`; + } else { + // No authentication for token generation endpoints + return ''; } } @@ -70,20 +74,44 @@ export class XmApi { maxRetries = 3, } = options; - // Set up default headers with auth + // Set up default headers const headers: Record = { 'Content-Type': 'application/json', 'Accept': 'application/json', ...defaultHeaders, - 'Authorization': this.createAuthorizationHeader(options), }; + // Add authorization header if we can determine it now + const authHeader = this.createAuthorizationHeader(options); + if (authHeader) { + headers['Authorization'] = authHeader; + } + const requestBuilder = new RequestBuilder(hostname, headers); - // Get onTokenRefresh callback if using OAuth - const onTokenRefresh = 'onTokenRefresh' in options ? options.onTokenRefresh : undefined; + // Get onTokenRefresh callback and token data if using OAuth + let onTokenRefresh: + | ((accessToken: string, refreshToken: string) => void | Promise) + | undefined; + let tokenData: TokenData | undefined; + + if (isOAuthOptions(options)) { + onTokenRefresh = options.onTokenRefresh; + tokenData = { + accessToken: options.accessToken, + refreshToken: options.refreshToken || '', + clientId: options.clientId, + }; + } - this.http = new RequestHandler(httpClient, logger, requestBuilder, maxRetries, onTokenRefresh); + this.http = new RequestHandler( + httpClient, + logger, + requestBuilder, + maxRetries, + onTokenRefresh, + tokenData, + ); // Initialize endpoints this.groups = new GroupsEndpoint(this.http); @@ -93,6 +121,7 @@ export class XmApi { // Re-export types export * from './core/types/internal/config.ts'; export * from './core/types/internal/http.ts'; +export * from './core/types/internal/oauth.ts'; export * from './core/types/endpoint/response.ts'; export * from './core/types/endpoint/composers.ts'; export * from './core/types/endpoint/params.ts'; From a599c465c398039025b4eee334e5bad6d2dbbeb8 Mon Sep 17 00:00:00 2001 From: johan Date: Fri, 6 Jun 2025 15:31:09 -0700 Subject: [PATCH 015/101] Simplify XmApi class, and make request-handler and request-builder make sense in preparation of oauth stuff --- src/core/request-builder.test.ts | 13 ++++ src/core/request-builder.ts | 5 +- src/core/request-handler.test.ts | 119 +++++++++++++++++++++++++------ src/core/request-handler.ts | 70 +++++++++++++----- src/core/test-utils.ts | 28 ++------ src/index.ts | 61 +++------------- 6 files changed, 184 insertions(+), 112 deletions(-) diff --git a/src/core/request-builder.test.ts b/src/core/request-builder.test.ts index 1a14eb4..95f5141 100644 --- a/src/core/request-builder.test.ts +++ b/src/core/request-builder.test.ts @@ -70,4 +70,17 @@ Deno.test('RequestBuilder', async (t) => { 'Either path or fullUrl must be provided', ); }); + + await t.step('merges headers correctly', () => { + const request = builder.build({ + path: '/people', + headers: { + 'custom-header': 'custom-value', + 'default-header': 'overridden-value', // Should override default + }, + }); + + assertEquals(request.headers?.['default-header'], 'overridden-value'); + assertEquals(request.headers?.['custom-header'], 'custom-value'); + }); }); diff --git a/src/core/request-builder.ts b/src/core/request-builder.ts index a6528ab..6b7854d 100644 --- a/src/core/request-builder.ts +++ b/src/core/request-builder.ts @@ -58,13 +58,16 @@ export class RequestBuilder { }); } + // Build headers by merging default headers with request-specific headers + const headers: Record = { ...this.defaultHeaders, ...options.headers }; + const builtRequest: HttpRequest = { method: options.method || 'GET', // For the path property, use the actual path provided or extract it from fullUrl path: options.path || options.fullUrl || '', url: url.toString(), query: options.query, - headers: { ...this.defaultHeaders, ...options.headers }, + headers, body: options.body, retryAttempt: options.retryAttempt || 0, }; diff --git a/src/core/request-handler.test.ts b/src/core/request-handler.test.ts index 4c4f482..5534cfd 100644 --- a/src/core/request-handler.test.ts +++ b/src/core/request-handler.test.ts @@ -4,9 +4,7 @@ import { assertRejects, } from 'https://deno.land/std@0.193.0/testing/asserts.ts'; import { RequestHandler } from './request-handler.ts'; -import { RequestBuilder } from './request-builder.ts'; import { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; -import { Logger } from './types/internal/config.ts'; import { XmApiError } from './errors.ts'; class TestHttpClient implements HttpClient { @@ -25,18 +23,32 @@ class TestHttpClient implements HttpClient { } } -const mockLogger: Logger = { +const mockLogger = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, }; +const basicOptions = { + hostname: 'https://example.com', + username: 'testuser', + password: 'password123', + defaultHeaders: {}, +}; + +const oauthOptions = { + hostname: 'https://example.com', + accessToken: 'access-token', + refreshToken: 'refresh-token', + clientId: 'client-id', + defaultHeaders: {}, +}; + Deno.test('RequestHandler', async (t) => { await t.step('handles non-JSON response bodies', async () => { const client = new TestHttpClient(); - const requestBuilder = new RequestBuilder('https://example.com'); - const handler = new RequestHandler(client, mockLogger, requestBuilder); + const handler = new RequestHandler(client, mockLogger, basicOptions, 3); client.responses = [{ status: 400, @@ -53,8 +65,7 @@ Deno.test('RequestHandler', async (t) => { await t.step('retries on rate limit with Retry-After', async () => { const client = new TestHttpClient(); - const requestBuilder = new RequestBuilder('https://example.com'); - const handler = new RequestHandler(client, mockLogger, requestBuilder); + const handler = new RequestHandler(client, mockLogger, basicOptions, 3); client.responses = [ { @@ -77,8 +88,7 @@ Deno.test('RequestHandler', async (t) => { await t.step('retries with exponential backoff on server error', async () => { const client = new TestHttpClient(); - const requestBuilder = new RequestBuilder('https://example.com'); - const handler = new RequestHandler(client, mockLogger, requestBuilder); + const handler = new RequestHandler(client, mockLogger, basicOptions, 3); client.responses = [ { @@ -101,8 +111,7 @@ Deno.test('RequestHandler', async (t) => { await t.step('stops retrying after max attempts', async () => { const client = new TestHttpClient(); - const requestBuilder = new RequestBuilder('https://example.com'); - const handler = new RequestHandler(client, mockLogger, requestBuilder); + const handler = new RequestHandler(client, mockLogger, basicOptions, 3); client.responses = Array(5).fill({ status: 503, @@ -121,8 +130,7 @@ Deno.test('RequestHandler', async (t) => { await t.step('handles network errors', async () => { const client = new TestHttpClient(); - const requestBuilder = new RequestBuilder('https://example.com'); - const handler = new RequestHandler(client, mockLogger, requestBuilder); + const handler = new RequestHandler(client, mockLogger, basicOptions, 3); client.forceError = new Error('Network error'); @@ -140,20 +148,74 @@ Deno.test('RequestHandler', async (t) => { } }); + await t.step('adds Basic Auth header to requests', async () => { + const client = new TestHttpClient(); + const handler = new RequestHandler(client, mockLogger, basicOptions, 3); + + client.responses = [{ + status: 200, + headers: { 'content-type': 'application/json' }, + body: { success: true }, + }]; + + await handler.get({ path: '/test' }); + + assertEquals(client.requests.length, 1); + const request = client.requests[0]; + assertExists(request.headers?.Authorization); + + // Verify it's Basic auth + const [authType] = request.headers.Authorization.split(' '); + assertEquals(authType, 'Basic'); + }); + + await t.step('adds OAuth Bearer token to requests', async () => { + const client = new TestHttpClient(); + const initialTokenState = { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + clientId: 'client-id', + expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), + scopes: [], + }; + const handler = new RequestHandler( + client, + mockLogger, + oauthOptions, + 3, + undefined, + initialTokenState, + ); + + client.responses = [{ + status: 200, + headers: { 'content-type': 'application/json' }, + body: { success: true }, + }]; + + await handler.get({ path: '/test' }); + + assertEquals(client.requests.length, 1); + const request = client.requests[0]; + assertEquals(request.headers?.Authorization, 'Bearer test-access-token'); + }); + await t.step('refreshes token on 401 response', async () => { const client = new TestHttpClient(); - const requestBuilder = new RequestBuilder('https://example.com'); + const initialTokenState = { + accessToken: 'old-token', + refreshToken: 'refresh-token', + clientId: 'client-id', + expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), + scopes: [], + }; const handler = new RequestHandler( client, mockLogger, - requestBuilder, + oauthOptions, 3, undefined, - { - accessToken: 'old-token', - refreshToken: 'refresh-token', - clientId: 'client-id', - }, + initialTokenState, ); client.responses = [ @@ -199,4 +261,21 @@ Deno.test('RequestHandler', async (t) => { const retriedRequest = client.requests[2]; assertEquals(retriedRequest.headers?.Authorization, 'Bearer new-token'); }); + + await t.step('skips auth headers when skipAuth is true', async () => { + const client = new TestHttpClient(); + const handler = new RequestHandler(client, mockLogger, basicOptions, 3); + + client.responses = [{ + status: 200, + headers: { 'content-type': 'application/json' }, + body: { success: true }, + }]; + + await handler.send({ path: '/oauth2/token', skipAuth: true }); + + assertEquals(client.requests.length, 1); + const request = client.requests[0]; + assertEquals(request.headers?.Authorization, undefined); + }); }); diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index 0783735..3f9d081 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -1,13 +1,20 @@ import { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; -import { Logger } from './types/internal/config.ts'; +import { + isBasicAuthOptions, + isOAuthOptions, + Logger, + XmApiOptions, +} from './types/internal/config.ts'; import { DeleteOptions, GetOptions, RequestWithBodyOptions } from './types/internal/methods.ts'; import { XmApiError } from './errors.ts'; -import { TokenData, TokenState } from './types/internal/oauth.ts'; +import { TokenState } from './types/internal/oauth.ts'; import { RequestBuilder } from './request-builder.ts'; export class RequestHandler { /** Current token state if using OAuth */ private tokenState?: TokenState; + /** Request builder for creating HTTP requests */ + private readonly requestBuilder: RequestBuilder; /** * Helper method to safely convert a response body to a string for error messages @@ -44,24 +51,27 @@ export class RequestHandler { constructor( private readonly client: HttpClient, private readonly logger: Logger, - private readonly requestBuilder: RequestBuilder, + private readonly options: XmApiOptions, private readonly maxRetries: number = 3, private readonly onTokenRefresh?: ( accessToken: string, refreshToken: string, ) => void | Promise, - tokenData?: TokenData, + tokenState?: TokenState, ) { - // If we have token data, initialize token state - if (tokenData) { - this.tokenState = { - ...tokenData, - // Set a default expiry 5 minutes from now - we'll get the real value on first refresh - expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), - scopes: [], - clientId: tokenData.clientId, - }; + // If we have token state, store it + if (tokenState) { + this.tokenState = tokenState; } + + // Create request builder + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...options.defaultHeaders, + }; + + this.requestBuilder = new RequestBuilder(options.hostname, headers); } private isTokenExpired(): boolean { @@ -156,11 +166,30 @@ export class RequestHandler { return Math.min(1000 * Math.pow(2, attempt), 10000); } + /** + * Creates the authorization header value based on the authentication type + */ + private createAuthHeader(): string | undefined { + if (isOAuthOptions(this.options)) { + // For OAuth, get the current access token from token state + const currentToken = this.tokenState?.accessToken; + return currentToken ? `Bearer ${currentToken}` : undefined; + } else if (isBasicAuthOptions(this.options)) { + // In Deno, we use TextEncoder for proper UTF-8 encoding + const encoder = new TextEncoder(); + const authString = `${this.options.username}:${this.options.password}`; + const auth = btoa(String.fromCharCode(...encoder.encode(authString))); + return `Basic ${auth}`; + } + return undefined; + } + async send( request: Partial & { path?: string; fullUrl?: string; method?: HttpRequest['method']; + skipAuth?: boolean; }, ): Promise> { // Check if token refresh is needed before making the request @@ -170,12 +199,15 @@ export class RequestHandler { const fullRequest = this.requestBuilder.build(request); - // Add authorization header if we have a token - if (this.tokenState?.accessToken) { - fullRequest.headers = { - ...fullRequest.headers, - Authorization: `Bearer ${this.tokenState.accessToken}`, - }; + // Add authorization header unless explicitly skipped + if (!request.skipAuth) { + const authHeader = this.createAuthHeader(); + if (authHeader) { + fullRequest.headers = { + ...fullRequest.headers, + Authorization: authHeader, + }; + } } try { diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts index 1ffa6dd..280b896 100644 --- a/src/core/test-utils.ts +++ b/src/core/test-utils.ts @@ -1,28 +1,8 @@ import { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; import { Logger, XmApiOptions } from './types/internal/config.ts'; import { XmApiError } from './errors.ts'; -import { RequestBuilder } from './request-builder.ts'; import { RequestHandler } from './request-handler.ts'; -class MockRequestBuilder extends RequestBuilder { - constructor() { - super('https://example.com'); - } - - override build(options: { path?: string; fullUrl?: string } & Partial): HttpRequest { - const url = options.fullUrl || `https://example.com${options.path || '/'}`; - return { - method: options.method || 'GET', - path: options.path || options.fullUrl || '/', - url, - query: options.query, - headers: options.headers || {}, - body: options.body, - retryAttempt: options.retryAttempt || 0, - }; - } -} - export class MockRequestHandler extends RequestHandler { public readonly requests: HttpRequest[] = []; private readonly responses: HttpResponse[]; @@ -39,9 +19,13 @@ export class MockRequestHandler extends RequestHandler { warn: () => {}, error: () => {}, }; - const mockRequestBuilder = new MockRequestBuilder(); + const mockOptions: XmApiOptions = { + hostname: 'https://example.com', + username: 'test-user', + password: 'test-password', + }; - super(mockClient, mockLogger, mockRequestBuilder); + super(mockClient, mockLogger, mockOptions); this.responses = Array.isArray(responses) ? responses : [responses]; } diff --git a/src/index.ts b/src/index.ts index 3dea0ba..8e7a809 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,7 @@ -import { RequestBuilder } from './core/request-builder.ts'; import { RequestHandler } from './core/request-handler.ts'; import { DefaultHttpClient, defaultLogger } from './core/defaults/index.ts'; -import { isBasicAuthOptions, isOAuthOptions, XmApiOptions } from './core/types/internal/config.ts'; +import { isOAuthOptions, XmApiOptions } from './core/types/internal/config.ts'; import { GroupsEndpoint } from './endpoints/groups/index.ts'; -import { TokenData } from './core/types/internal/oauth.ts'; /** * Main entry point for the xMatters API client. @@ -47,70 +45,33 @@ export class XmApi { /** Access groups-related endpoints */ public readonly groups: GroupsEndpoint; - /** - * Creates the authorization header value based on the authentication type - */ - private createAuthorizationHeader(options: XmApiOptions): string { - if (isOAuthOptions(options)) { - return `Bearer ${options.accessToken}`; - } else if (isBasicAuthOptions(options)) { - // In Deno, we use TextEncoder for proper UTF-8 encoding - const encoder = new TextEncoder(); - const authString = `${options.username}:${options.password}`; - const auth = btoa(String.fromCharCode(...encoder.encode(authString))); - return `Basic ${auth}`; - } else { - // No authentication for token generation endpoints - return ''; - } - } - - constructor(options: XmApiOptions) { + constructor(private readonly options: XmApiOptions) { const { - hostname, httpClient = new DefaultHttpClient(), logger = defaultLogger, - defaultHeaders = {}, maxRetries = 3, } = options; - // Set up default headers - const headers: Record = { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - ...defaultHeaders, - }; - - // Add authorization header if we can determine it now - const authHeader = this.createAuthorizationHeader(options); - if (authHeader) { - headers['Authorization'] = authHeader; - } - - const requestBuilder = new RequestBuilder(hostname, headers); - - // Get onTokenRefresh callback and token data if using OAuth - let onTokenRefresh: - | ((accessToken: string, refreshToken: string) => void | Promise) - | undefined; - let tokenData: TokenData | undefined; - + // Create initial token state for OAuth if needed + let initialTokenState; if (isOAuthOptions(options)) { - onTokenRefresh = options.onTokenRefresh; - tokenData = { + initialTokenState = { accessToken: options.accessToken, refreshToken: options.refreshToken || '', clientId: options.clientId, + // Set a default expiry 5 minutes from now - we'll get the real value on first refresh + expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), + scopes: [], }; } this.http = new RequestHandler( httpClient, logger, - requestBuilder, + options, maxRetries, - onTokenRefresh, - tokenData, + isOAuthOptions(options) ? options.onTokenRefresh : undefined, + initialTokenState, ); // Initialize endpoints From f873829d0268bdf46dacd341e0516b93a6c7e504 Mon Sep 17 00:00:00 2001 From: johan Date: Sat, 7 Jun 2025 18:06:00 -0700 Subject: [PATCH 016/101] Got started on refactoring tests so they make sense Establishing a better pattern for all endpoints testing --- deno.json | 2 +- deno.lock | 72 +++- src/endpoints/groups/index.test.ts | 649 +++++++++++++++++++++-------- 3 files changed, 544 insertions(+), 179 deletions(-) diff --git a/deno.json b/deno.json index 5e2c1eb..2fc99bc 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "imports": { - "std/": "https://deno.land/std@0.193.0/" + "std/": "https://deno.land/std@0.224.0/" }, "tasks": { "test": "DENO_TLS_CA_STORE=system deno test", diff --git a/deno.lock b/deno.lock index ed6b6e5..e99d24b 100644 --- a/deno.lock +++ b/deno.lock @@ -1,9 +1,79 @@ { "version": "5", + "redirects": { + "https://deno.land/std/expect/mod.ts": "https://deno.land/std@0.224.0/expect/mod.ts" + }, "remote": { + "https://deno.land/std@0.193.0/async/delay.ts": "73aa04cec034c84fc748c7be49bb15cac3dd43a57174bfdb7a4aec22c248f0dd", + "https://deno.land/std@0.193.0/collections/_comparators.ts": "fa7f9a44cea1d270098a2a5a6f8bb30c61b595c1b1f983bd67c6297d766adffa", + "https://deno.land/std@0.193.0/collections/binary_search_node.ts": "8d99dd95901d73a0edbe105826ef7ce0e1111ce184d2d0410dbfda172c9ebf35", + "https://deno.land/std@0.193.0/collections/binary_search_tree.ts": "47b5d09bf6567a674918dd760ba62e2fb250552580361460fa9c58a399c9f3af", + "https://deno.land/std@0.193.0/collections/red_black_node.ts": "eb766a69d82132fc4f1789eb3dc753781da7c3b0938756256be3764c9941e3ac", + "https://deno.land/std@0.193.0/collections/red_black_tree.ts": "9dade0abb93cdb7cfd978dcdd01fe6f1bb3f14fdb4e54502367d3029edca01ec", "https://deno.land/std@0.193.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e", "https://deno.land/std@0.193.0/testing/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", "https://deno.land/std@0.193.0/testing/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", - "https://deno.land/std@0.193.0/testing/asserts.ts": "056d571baaefc7f13af3e29ad6a66d4dbe5355d3cb2ae130e7d2a1b1e01085e3" + "https://deno.land/std@0.193.0/testing/_time.ts": "fecaf6fc7277d240d11b0de2e93b1c93ebbb4a3a61f0cb0b1741f66f69a4d22b", + "https://deno.land/std@0.193.0/testing/asserts.ts": "056d571baaefc7f13af3e29ad6a66d4dbe5355d3cb2ae130e7d2a1b1e01085e3", + "https://deno.land/std@0.193.0/testing/time.ts": "a46fbfd61e6f011f15a63c8078399b1f7fa848d2c0c526f253b0535f5c3e7f45", + "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", + "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", + "https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", + "https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7", + "https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74", + "https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd", + "https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff", + "https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46", + "https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b", + "https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c", + "https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491", + "https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68", + "https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3", + "https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7", + "https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29", + "https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a", + "https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a", + "https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8", + "https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693", + "https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31", + "https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5", + "https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8", + "https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb", + "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", + "https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47", + "https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68", + "https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3", + "https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73", + "https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19", + "https://deno.land/std@0.224.0/async/delay.ts": "f90dd685b97c2f142b8069082993e437b1602b8e2561134827eeb7c12b95c499", + "https://deno.land/std@0.224.0/data_structures/_binary_search_node.ts": "ce1da11601fef0638df4d1e53c377f791f96913383277389286b390685d76c07", + "https://deno.land/std@0.224.0/data_structures/_red_black_node.ts": "4af8d3c5ac5f119d8058269259c46ea22ead567246cacde04584a83e43a9d2ea", + "https://deno.land/std@0.224.0/data_structures/binary_search_tree.ts": "2dd43d97ce5f5a4bdba11b075eb458db33e9143f50997b0eebf02912cb44f5d5", + "https://deno.land/std@0.224.0/data_structures/comparators.ts": "17dfa68bf1550edadbfdd453a06f9819290bcb534c9945b5cec4b30242cff475", + "https://deno.land/std@0.224.0/data_structures/red_black_tree.ts": "2222be0c46842fc932e2c8589a66dced9e6eae180914807c5c55d1aa4c8c1b9b", + "https://deno.land/std@0.224.0/expect/_assert_equals.ts": "08a5ba74c1c7ca51c4c2c33158509746c560777ad3cb8996a55d85a8d57c351c", + "https://deno.land/std@0.224.0/expect/_assert_not_equals.ts": "f8c56aafbc12b2d49bb5e1478f02a3ae8a6fd884eff18ccbc899a94829eb1510", + "https://deno.land/std@0.224.0/expect/_asymmetric_matchers.ts": "bf2385fc9a943f0600f7870c8dfcbfd0ab5d633fe3c45b84339016e0d8069ac4", + "https://deno.land/std@0.224.0/expect/_build_message.ts": "5381029035b3ff64839167f1be3e36ee2c5c5f062878f50b8d060407945206c6", + "https://deno.land/std@0.224.0/expect/_constants.ts": "751a014c4b803ad21287529d55d213d8b5eb34d203cca9509e4c9b84ced440a8", + "https://deno.land/std@0.224.0/expect/_custom_equality_tester.ts": "9426916863d2b740ae3ec74e2de5f2ec79d36bb1a62bdd5965a9b9543c8ee46d", + "https://deno.land/std@0.224.0/expect/_equal.ts": "f2d4f3a10f91cd6c21deefc8facdbc9e6f07a828e68b8e50049a33f8a4c3c6d4", + "https://deno.land/std@0.224.0/expect/_extend.ts": "df775060bffb0756519aa20663ecea5503167a8f13f5aef9cf18c24f16161ca3", + "https://deno.land/std@0.224.0/expect/_inspect_args.ts": "6cc9b3809e6f9671b35d0305d1ed7cc340b00b0455731b0b47b6c2ae707f8f27", + "https://deno.land/std@0.224.0/expect/_matchers.ts": "5e1056b4243fc3094381c9db0783ce0d47559fa11745c63227cf01c0352cb4ed", + "https://deno.land/std@0.224.0/expect/_mock_util.ts": "7e79e07eb869ff71b96601ac76807c745465bd3c5a3df622abf44c28a0c4ca8c", + "https://deno.land/std@0.224.0/expect/_snapshot_serializer.ts": "de8cf3b1d7005789ea35ce0052146a4a42ce8d36fe947c712db7147b83ffa778", + "https://deno.land/std@0.224.0/expect/_types.ts": "a44a35d8566a51350d38ff8e902e2bfeee502dbb72282da5aee214ae98c6f70e", + "https://deno.land/std@0.224.0/expect/_utils.ts": "fc45069227d1c5a04f642b9224f060017eb02923159a4848d79a7a3fcef53c55", + "https://deno.land/std@0.224.0/expect/expect.ts": "474fa2c581c861be43bf4fcf58875d7d28b879bea1d2c671b833b2147ced24ab", + "https://deno.land/std@0.224.0/expect/fn.ts": "2508684de0a3147698b3b162f6fc99f7f9fe424ba3136fc4016f654aaff243cd", + "https://deno.land/std@0.224.0/expect/mod.ts": "dc79508c6f554d70ef087fdb726d09add7a48f5e612aabc2d951e5cc69c4529a", + "https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5", + "https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6", + "https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2", + "https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e", + "https://deno.land/std@0.224.0/testing/_time.ts": "fefd1ff35b50a410db9b0e7227e05163e1b172c88afd0d2071df0125958c3ff3", + "https://deno.land/std@0.224.0/testing/mock.ts": "a963181c2860b6ba3eb60e08b62c164d33cf5da7cd445893499b2efda20074db", + "https://deno.land/std@0.224.0/testing/time.ts": "7119072a198e9913da0d21106b1f05a90a4c05b07075529770ff0e2a9eb5eaba" } } diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index 86442a6..1958408 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -1,229 +1,524 @@ -import { - assertEquals, - assertExists, - assertObjectMatch, - assertStringIncludes, -} from 'https://deno.land/std@0.193.0/testing/asserts.ts'; +/** + * Comprehensive test suite for GroupsEndpoint using Deno's standard testing library. + * + * Testing Philosophy: + * - Only mock the HttpClient to prevent actual network calls + * - All other library code runs real implementation + * - Verify exact HTTP requests sent by inspecting stub call arguments + * - Test all endpoint methods + * - Test both Basic Auth and OAuth authentication + * - Test error scenarios (HTTP errors and network failures) + * - Test parameter handling and URL construction + * - Test different configuration options (hostname, auth methods) + * + * This approach ensures: + * - High confidence that real library code works correctly + * - Fast test execution (no network I/O) + * - Clear verification of what HTTP requests are actually sent + * - Easy maintenance as it focuses on the interface contract + */ + +import { expect } from 'https://deno.land/std@0.224.0/expect/mod.ts'; +import { stub } from 'https://deno.land/std@0.224.0/testing/mock.ts'; +import { FakeTime } from 'https://deno.land/std@0.224.0/testing/time.ts'; + import { GroupsEndpoint } from './index.ts'; -import { GetGroupsResponse } from './types.ts'; -import { createMockResponse, MockRequestHandler } from '../../core/test-utils.ts'; +import { RequestHandler } from '../../core/request-handler.ts'; +import type { HttpClient, HttpRequest } from '../../core/types/internal/http.ts'; +import type { Logger, XmApiOptions } from '../../core/types/internal/config.ts'; +import type { Group } from './types.ts'; +import type { TokenState } from '../../core/types/internal/oauth.ts'; import { XmApiError } from '../../core/errors.ts'; -const mockGroup = { - id: '123', +// Test helper to create mock setup +function createEndpointTestSetup(options: { + hostname?: string; + username?: string; + password?: string; + accessToken?: string; + refreshToken?: string; + clientId?: string; + maxRetries?: number; +} = {}) { + const { + hostname = 'https://example.xmatters.com', + username = 'test-user', + password = 'test-password', + accessToken, + refreshToken, + clientId, + maxRetries = 3, + } = options; + + // Create silent mock logger + const mockLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }; + + // Create auth options based on provided parameters + const mockOptions: XmApiOptions = accessToken + ? { hostname, accessToken, refreshToken, clientId } + : { hostname, username, password }; + + // Create mock HTTP client - this is the ONLY thing we mock + const mockHttpClient: HttpClient = { + send: () => Promise.resolve({ status: 200, headers: {}, body: {} }), + }; + + // Create token state for OAuth options if needed + let tokenState: TokenState | undefined; + if (accessToken) { + tokenState = { + accessToken, + refreshToken: refreshToken || '', + clientId, + expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), // 5 minutes from now + scopes: [], + }; + } + + const requestHandler = new RequestHandler( + mockHttpClient, + mockLogger, + mockOptions, + maxRetries, + undefined, // onTokenRefresh callback + tokenState, + ); + const endpoint = new GroupsEndpoint(requestHandler); + + return { mockHttpClient, endpoint, mockLogger }; +} + +// Mock data for tests +const mockGroup: Group = { + id: 'test-group-123', targetName: 'Test Group', - recipientType: 'GROUP' as const, - status: 'ACTIVE' as const, - groupType: 'ON_CALL' as const, - created: '2025-05-31T00:00:00Z', - description: 'Test group description', - supervisors: ['user1'], - externallyOwned: false, - allowDuplicates: true, - useDefaultDevices: true, - observedByAll: true, - links: { - self: '/api/xm/1/groups/123', - }, + recipientType: 'GROUP', + status: 'ACTIVE', + groupType: 'ON_CALL', + created: '2024-01-01T00:00:00Z', + description: 'Test group for unit tests', }; -const mockGroupsResponse: GetGroupsResponse = { - count: 1, - total: 1, - data: [mockGroup], - links: { - self: 'https://example.com/api/xm/1/groups', +const mockGroupsList: Group[] = [ + mockGroup, + { + id: 'test-group-456', + targetName: 'Another Group', + recipientType: 'GROUP', + status: 'ACTIVE', + groupType: 'BROADCAST', + created: '2024-01-02T00:00:00Z', + }, +]; + +const mockPaginatedResponse = { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { + count: 2, + total: 10, + data: mockGroupsList, }, }; +const mockSingleGroupResponse = { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockGroup, +}; + +const mockEmptyResponse = { + status: 204, + headers: {}, + body: undefined, +}; + Deno.test('GroupsEndpoint', async (t) => { - await t.step('getGroups without parameters', async () => { - const mockResponse = createMockResponse({ - body: mockGroupsResponse, - headers: { - 'content-type': 'application/json', - }, - }); - const mockHttp = new MockRequestHandler(mockResponse); - const endpoint = new GroupsEndpoint(mockHttp); + await t.step('getGroups() - sends correct HTTP request with no params', async () => { + const { mockHttpClient, endpoint } = createEndpointTestSetup(); + const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); + + try { + const result = await endpoint.getGroups(); - const response = await endpoint.getGroups(); + // Verify HTTP client was called exactly once + expect(sendStub.calls.length).toBe(1); - assertEquals(response.body, mockGroupsResponse); - assertEquals(mockHttp.requests.length, 1); + // Verify the request details + const sentRequest: HttpRequest = sendStub.calls[0].args[0]; + expect(sentRequest.method).toBe('GET'); + expect(sentRequest.path).toBe('/groups'); + expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups'); + expect(sentRequest.query).toBeUndefined(); + expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); + expect(sentRequest.headers?.['Accept']).toBe('application/json'); + expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); + expect(sentRequest.body).toBeUndefined(); - const request = mockHttp.requests[0]; - assertEquals(request.method, 'GET'); - assertEquals(request.path, '/groups'); - assertEquals(request.query, undefined); + // Verify response is returned correctly + expect(result).toEqual(mockPaginatedResponse); + } finally { + sendStub.restore(); + } }); - await t.step('getGroups with parameters', async () => { - const mockResponse = createMockResponse({ - body: mockGroupsResponse, - headers: { - 'content-type': 'application/json', - }, - }); - const mockHttp = new MockRequestHandler(mockResponse); - const endpoint = new GroupsEndpoint(mockHttp); + await t.step('getGroups() - sends correct HTTP request with pagination params', async () => { + const { mockHttpClient, endpoint } = createEndpointTestSetup(); + const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); + + try { + await endpoint.getGroups({ limit: 10, offset: 20 }); - const params = { limit: 10, offset: 0, search: 'test' }; - const response = await endpoint.getGroups(params); + const sentRequest: HttpRequest = sendStub.calls[0].args[0]; + expect(sentRequest.method).toBe('GET'); + expect(sentRequest.path).toBe('/groups'); + expect(sentRequest.query).toEqual({ limit: 10, offset: 20 }); + } finally { + sendStub.restore(); + } + }); - assertEquals(response.body, mockGroupsResponse); - assertEquals(mockHttp.requests.length, 1); + await t.step('getGroups() - sends correct HTTP request with search params', async () => { + const { mockHttpClient, endpoint } = createEndpointTestSetup(); + const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); - const request = mockHttp.requests[0]; - assertEquals(request.method, 'GET'); - assertEquals(request.path, '/groups'); - assertExists(request.query); - assertObjectMatch(request.query, params); + try { + await endpoint.getGroups({ search: 'oncall', limit: 5 }); + + const sentRequest: HttpRequest = sendStub.calls[0].args[0]; + expect(sentRequest.method).toBe('GET'); + expect(sentRequest.path).toBe('/groups'); + expect(sentRequest.query).toEqual({ search: 'oncall', limit: 5 }); + } finally { + sendStub.restore(); + } }); - await t.step('getGroups handles errors', async () => { - const errorResponse = createMockResponse({ - body: { message: 'Not Found' }, - status: 404, - headers: { - 'content-type': 'application/json', - }, + await t.step('getById() - sends correct HTTP request', async () => { + const { mockHttpClient, endpoint } = createEndpointTestSetup(); + const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockSingleGroupResponse)); + + try { + const result = await endpoint.getById('test-group-123'); + + // Verify HTTP client was called correctly + expect(sendStub.calls.length).toBe(1); + + const sentRequest: HttpRequest = sendStub.calls[0].args[0]; + expect(sentRequest.method).toBe('GET'); + expect(sentRequest.path).toBe('/groups/test-group-123'); + expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups/test-group-123'); + expect(sentRequest.query).toBeUndefined(); + expect(sentRequest.body).toBeUndefined(); + + // Verify response + expect(result).toEqual(mockSingleGroupResponse); + } finally { + sendStub.restore(); + } + }); + + await t.step('save() - sends correct HTTP request for creating group', async () => { + const { mockHttpClient, endpoint } = createEndpointTestSetup(); + const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockSingleGroupResponse)); + + const newGroup = { + targetName: 'New Group', + groupType: 'BROADCAST' as const, + description: 'A new test group', + }; + + try { + const result = await endpoint.save(newGroup); + + // Verify HTTP client was called correctly + expect(sendStub.calls.length).toBe(1); + + const sentRequest: HttpRequest = sendStub.calls[0].args[0]; + expect(sentRequest.method).toBe('POST'); + expect(sentRequest.path).toBe('/groups'); + expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups'); + expect(sentRequest.query).toBeUndefined(); + expect(sentRequest.body).toEqual(newGroup); + + // Verify response + expect(result).toEqual(mockSingleGroupResponse); + } finally { + sendStub.restore(); + } + }); + + await t.step('delete() - sends correct HTTP request', async () => { + const { mockHttpClient, endpoint } = createEndpointTestSetup(); + const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockEmptyResponse)); + + try { + const result = await endpoint.delete('test-group-123'); + + // Verify HTTP client was called correctly + expect(sendStub.calls.length).toBe(1); + + const sentRequest: HttpRequest = sendStub.calls[0].args[0]; + expect(sentRequest.method).toBe('DELETE'); + expect(sentRequest.path).toBe('/groups/test-group-123'); + expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups/test-group-123'); + expect(sentRequest.query).toBeUndefined(); + expect(sentRequest.body).toBeUndefined(); + + // Verify response + expect(result).toEqual(mockEmptyResponse); + } finally { + sendStub.restore(); + } + }); + + await t.step('OAuth authentication - sends correct Authorization header', async () => { + const { mockHttpClient, endpoint } = createEndpointTestSetup({ + accessToken: 'test-access-token', + hostname: 'https://oauth.xmatters.com', }); - const mockHttp = new MockRequestHandler(errorResponse); - const endpoint = new GroupsEndpoint(mockHttp); + const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); try { await endpoint.getGroups(); - throw new Error('Expected error to be thrown'); - } catch (error) { - if (!(error instanceof Error)) { - throw new Error('Expected XmApiError but got: ' + String(error)); - } - assertEquals(error.name, 'XmApiError'); - assertEquals(error.message, 'Not Found'); - // Type assertion since we know it's an XmApiError - const xmError = error as XmApiError; - assertExists(xmError.response); - assertEquals(xmError.response.status, 404); + + const sentRequest: HttpRequest = sendStub.calls[0].args[0]; + expect(sentRequest.headers?.['Authorization']).toBe('Bearer test-access-token'); + expect(sentRequest.url).toBe('https://oauth.xmatters.com/api/xm/1/groups'); + } finally { + sendStub.restore(); } }); -}); -Deno.test('GroupsEndpoint error handling', async (t) => { - await t.step('retries on rate limit with Retry-After', async () => { - const rateLimitResponse = createMockResponse({ - body: { message: 'Too many requests' }, - status: 429, - headers: { - 'retry-after': '2', - 'content-type': 'application/json', - }, - }); - const successResponse = createMockResponse({ - body: mockGroupsResponse, - headers: { - 'content-type': 'application/json', - }, - }); + await t.step('Error handling - throws XmApiError on HTTP error', async () => { + const { mockHttpClient, endpoint } = createEndpointTestSetup(); + const errorResponse = { + status: 404, + headers: { 'content-type': 'application/json' }, + body: { message: 'Group not found' }, + }; + const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(errorResponse)); + + try { + let thrownError: unknown; + try { + await endpoint.getById('non-existent-group'); + } catch (error) { + thrownError = error; + } - const mockHttp = new MockRequestHandler([rateLimitResponse, successResponse]); - const endpoint = new GroupsEndpoint(mockHttp); + // Verify error was thrown + expect(thrownError).toBeInstanceOf(XmApiError); + const xmError = thrownError as XmApiError; + expect(xmError.message).toBe('Group not found'); // Uses the message from response body + expect(xmError.response?.status).toBe(404); + expect(xmError.response?.body).toBe('{"message":"Group not found"}'); + } finally { + sendStub.restore(); + } + }); - const response = await endpoint.getGroups(); - assertEquals(response.body, mockGroupsResponse); - assertEquals(mockHttp.requests.length, 2); + await t.step('Error handling - throws XmApiError on network error', async () => { + const { mockHttpClient, endpoint } = createEndpointTestSetup(); + const networkError = new Error('Network connection failed'); + const sendStub = stub(mockHttpClient, 'send', () => Promise.reject(networkError)); - // Verify retry was attempted - const [firstRequest, retryRequest] = mockHttp.requests; - assertEquals(firstRequest.retryAttempt, 0); - assertEquals(retryRequest.retryAttempt, 1); + try { + let thrownError: unknown; + try { + await endpoint.getGroups(); + } catch (error) { + thrownError = error; + } + + // Verify error was thrown and wrapped + expect(thrownError).toBeInstanceOf(XmApiError); + const xmError = thrownError as XmApiError; + expect(xmError.message).toBe('Request failed'); // Generic message for network errors + expect(xmError.response).toBeUndefined(); // No response for network errors + } finally { + sendStub.restore(); + } }); - await t.step('handles detailed error responses', async () => { - const errorResponse = createMockResponse({ - body: { - code: 'VALIDATION_ERROR', - message: 'Invalid input', - details: [ - { field: 'targetName', message: 'Must not be empty' }, - ], - }, - status: 400, - headers: { - 'content-type': 'application/json', - 'request-id': 'test-123', - }, + await t.step('Custom hostname - uses correct base URL', async () => { + const { mockHttpClient, endpoint } = createEndpointTestSetup({ + hostname: 'https://custom.xmatters.com', }); - const mockHttp = new MockRequestHandler(errorResponse); - const endpoint = new GroupsEndpoint(mockHttp); + const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); try { await endpoint.getGroups(); - throw new Error('Expected error to be thrown'); - } catch (error) { - assertExists(error); - assertEquals(error instanceof XmApiError, true); - const xmError = error as XmApiError; - - // Verify error message has all the context - assertEquals(xmError.message, 'VALIDATION_ERROR: Invalid input'); - - // Verify response is preserved - assertExists(xmError.response); - assertEquals(xmError.response.status, 400); - assertEquals(xmError.response.headers['request-id'], 'test-123'); + + const sentRequest: HttpRequest = sendStub.calls[0].args[0]; + expect(sentRequest.url).toBe('https://custom.xmatters.com/api/xm/1/groups'); + } finally { + sendStub.restore(); } }); - await t.step('handles errors without response body', async () => { - const errorResponse = createMockResponse({ - body: '', // Empty response body - status: 502, - headers: { - 'content-type': 'text/plain', - }, + await t.step('Basic auth - sends correct Authorization header', async () => { + const { mockHttpClient, endpoint } = createEndpointTestSetup({ + username: 'testuser', + password: 'testpass', }); - const mockHttp = new MockRequestHandler(errorResponse); - const endpoint = new GroupsEndpoint(mockHttp); + const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); try { await endpoint.getGroups(); - throw new Error('Expected error to be thrown'); - } catch (error) { - assertExists(error); - assertEquals(error instanceof XmApiError, true); - const xmError = error as XmApiError; - - // Verify fallback error message - assertStringIncludes(xmError.message, '502'); - assertExists(xmError.response); - assertEquals(xmError.response.status, 502); - assertEquals(xmError.response.body, ''); + + const sentRequest: HttpRequest = sendStub.calls[0].args[0]; + expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); + + // Verify the basic auth encoding + const authPart = sentRequest.headers?.['Authorization']?.split(' ')[1]; + const decoded = atob(authPart!); + expect(decoded).toBe('testuser:testpass'); + } finally { + sendStub.restore(); } }); - await t.step('handles network errors', async () => { - const mockHttp = new MockRequestHandler({ - status: 0, // No status indicates network error - headers: {}, - body: undefined, - }); - mockHttp.forceError = new Error('Network error'); + await t.step('save() with full group object - sends all fields correctly', async () => { + const { mockHttpClient, endpoint } = createEndpointTestSetup(); + const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockSingleGroupResponse)); - const endpoint = new GroupsEndpoint(mockHttp); + const fullGroup: Partial = { + id: 'existing-group-123', + targetName: 'Updated Group', + recipientType: 'GROUP', + status: 'ACTIVE', + groupType: 'ON_CALL', + description: 'Updated test group', + supervisors: ['user1', 'user2'], + }; try { - await endpoint.getGroups(); - throw new Error('Expected error to be thrown'); - } catch (error) { - assertExists(error); - assertEquals(error instanceof XmApiError, true); - const xmError = error as XmApiError; - - assertEquals(xmError.message, 'Request failed'); - assertEquals(xmError.response, undefined); - assertEquals(xmError.cause instanceof Error, true); - assertStringIncludes((xmError.cause as Error).message, 'Network error'); + await endpoint.save(fullGroup); + + const sentRequest: HttpRequest = sendStub.calls[0].args[0]; + expect(sentRequest.method).toBe('POST'); + expect(sentRequest.body).toEqual(fullGroup); + expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); + } finally { + sendStub.restore(); + } + }); + + await t.step('getGroups() with all possible parameters', async () => { + const { mockHttpClient, endpoint } = createEndpointTestSetup(); + const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); + + const params = { + limit: 25, + offset: 50, + search: 'test search', + // Add other params that might exist in GetGroupsParams + }; + + try { + await endpoint.getGroups(params); + + const sentRequest: HttpRequest = sendStub.calls[0].args[0]; + expect(sentRequest.query).toEqual(params); + } finally { + sendStub.restore(); + } + }); + + await t.step('retries on rate limit with Retry-After', async () => { + const fakeTime = new FakeTime(); + + try { + const { mockHttpClient, endpoint } = createEndpointTestSetup(); + + const rateLimitResponse = { + status: 429, + headers: { 'retry-after': '2', 'content-type': 'application/json' }, + body: { message: 'Too many requests' }, + }; + const successResponse = { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { count: 2, total: 10, data: mockGroupsList }, + }; + + let callCount = 0; + const sendStub = stub(mockHttpClient, 'send', () => { + callCount++; + return callCount === 1 + ? Promise.resolve(rateLimitResponse) + : Promise.resolve(successResponse); + }); + + try { + // Start the request + const requestPromise = endpoint.getGroups(); + + // Allow the first request to complete and set up the timer + await fakeTime.nextAsync(); + // Now advance time to trigger the retry + await fakeTime.nextAsync(); + + const response = await requestPromise; + expect(response.body).toEqual(successResponse.body); + expect(sendStub.calls.length).toBe(2); + + // Verify both calls were GET requests to /groups + const firstRequest: HttpRequest = sendStub.calls[0].args[0]; + expect(firstRequest.method).toBe('GET'); + expect(firstRequest.path).toBe('/groups'); + + const retryRequest: HttpRequest = sendStub.calls[1].args[0]; + expect(retryRequest.method).toBe('GET'); + expect(retryRequest.path).toBe('/groups'); + } finally { + sendStub.restore(); + } + } finally { + fakeTime.restore(); + } + }); + + await t.step('handles errors without response body', async () => { + const { mockHttpClient, endpoint } = createEndpointTestSetup(); + + const errorResponse = { + status: 400, // Use 400 instead of 502 to avoid retry logic + headers: { 'content-type': 'text/plain' }, + body: '', // Empty response body + }; + + const sendStub = stub(mockHttpClient, 'send', () => { + return Promise.resolve(errorResponse); + }); + + try { + let thrownError: unknown; + try { + await endpoint.getGroups(); + } catch (error) { + thrownError = error; + } + + // Verify error was thrown + expect(thrownError).toBeInstanceOf(XmApiError); + const xmError = thrownError as XmApiError; + + // Verify fallback error message includes status code + expect(xmError.message).toContain('400'); + expect(xmError.response?.status).toBe(400); + expect(xmError.response?.body).toBe(''); + + // Verify it was called only once (no retries for 400) + expect(sendStub.calls.length).toBe(1); + } finally { + sendStub.restore(); } }); }); From 16a1b8d9247b20413c348f6689c8fe8e65f4bf77 Mon Sep 17 00:00:00 2001 From: johan Date: Sun, 8 Jun 2025 01:17:38 -0700 Subject: [PATCH 017/101] Continue unit test refactoring --- src/endpoints/groups/index.test.ts | 179 +++++++++++++++-------------- 1 file changed, 95 insertions(+), 84 deletions(-) diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index 1958408..8a7ecfb 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -63,7 +63,6 @@ function createEndpointTestSetup(options: { ? { hostname, accessToken, refreshToken, clientId } : { hostname, username, password }; - // Create mock HTTP client - this is the ONLY thing we mock const mockHttpClient: HttpClient = { send: () => Promise.resolve({ status: 200, headers: {}, body: {} }), }; @@ -142,13 +141,10 @@ Deno.test('GroupsEndpoint', async (t) => { await t.step('getGroups() - sends correct HTTP request with no params', async () => { const { mockHttpClient, endpoint } = createEndpointTestSetup(); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); - try { - const result = await endpoint.getGroups(); - + const response = await endpoint.getGroups(); // Verify HTTP client was called exactly once expect(sendStub.calls.length).toBe(1); - // Verify the request details const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); @@ -159,9 +155,8 @@ Deno.test('GroupsEndpoint', async (t) => { expect(sentRequest.headers?.['Accept']).toBe('application/json'); expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); expect(sentRequest.body).toBeUndefined(); - // Verify response is returned correctly - expect(result).toEqual(mockPaginatedResponse); + expect(response).toEqual(mockPaginatedResponse); } finally { sendStub.restore(); } @@ -170,14 +165,20 @@ Deno.test('GroupsEndpoint', async (t) => { await t.step('getGroups() - sends correct HTTP request with pagination params', async () => { const { mockHttpClient, endpoint } = createEndpointTestSetup(); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); - try { - await endpoint.getGroups({ limit: 10, offset: 20 }); - + const response = await endpoint.getGroups({ limit: 10, offset: 20 }); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); expect(sentRequest.path).toBe('/groups'); + expect(sentRequest.url).toBe( + 'https://example.xmatters.com/api/xm/1/groups?limit=10&offset=20', + ); expect(sentRequest.query).toEqual({ limit: 10, offset: 20 }); + expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); + expect(sentRequest.headers?.['Accept']).toBe('application/json'); + expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); + expect(sentRequest.body).toBeUndefined(); + expect(response).toEqual(mockPaginatedResponse); } finally { sendStub.restore(); } @@ -186,14 +187,20 @@ Deno.test('GroupsEndpoint', async (t) => { await t.step('getGroups() - sends correct HTTP request with search params', async () => { const { mockHttpClient, endpoint } = createEndpointTestSetup(); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); - try { - await endpoint.getGroups({ search: 'oncall', limit: 5 }); - + const response = await endpoint.getGroups({ search: 'oncall', limit: 5 }); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); expect(sentRequest.path).toBe('/groups'); + expect(sentRequest.url).toBe( + 'https://example.xmatters.com/api/xm/1/groups?search=oncall&limit=5', + ); expect(sentRequest.query).toEqual({ search: 'oncall', limit: 5 }); + expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); + expect(sentRequest.headers?.['Accept']).toBe('application/json'); + expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); + expect(sentRequest.body).toBeUndefined(); + expect(response).toEqual(mockPaginatedResponse); } finally { sendStub.restore(); } @@ -202,22 +209,19 @@ Deno.test('GroupsEndpoint', async (t) => { await t.step('getById() - sends correct HTTP request', async () => { const { mockHttpClient, endpoint } = createEndpointTestSetup(); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockSingleGroupResponse)); - try { - const result = await endpoint.getById('test-group-123'); - - // Verify HTTP client was called correctly + const response = await endpoint.getById('test-group-123'); expect(sendStub.calls.length).toBe(1); - const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); expect(sentRequest.path).toBe('/groups/test-group-123'); expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups/test-group-123'); expect(sentRequest.query).toBeUndefined(); + expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); + expect(sentRequest.headers?.['Accept']).toBe('application/json'); + expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); expect(sentRequest.body).toBeUndefined(); - - // Verify response - expect(result).toEqual(mockSingleGroupResponse); + expect(response).toEqual(mockSingleGroupResponse); } finally { sendStub.restore(); } @@ -226,28 +230,24 @@ Deno.test('GroupsEndpoint', async (t) => { await t.step('save() - sends correct HTTP request for creating group', async () => { const { mockHttpClient, endpoint } = createEndpointTestSetup(); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockSingleGroupResponse)); - const newGroup = { targetName: 'New Group', groupType: 'BROADCAST' as const, description: 'A new test group', }; - try { - const result = await endpoint.save(newGroup); - - // Verify HTTP client was called correctly + const response = await endpoint.save(newGroup); expect(sendStub.calls.length).toBe(1); - const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('POST'); expect(sentRequest.path).toBe('/groups'); expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups'); expect(sentRequest.query).toBeUndefined(); + expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); + expect(sentRequest.headers?.['Accept']).toBe('application/json'); + expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); expect(sentRequest.body).toEqual(newGroup); - - // Verify response - expect(result).toEqual(mockSingleGroupResponse); + expect(response).toEqual(mockSingleGroupResponse); } finally { sendStub.restore(); } @@ -256,22 +256,16 @@ Deno.test('GroupsEndpoint', async (t) => { await t.step('delete() - sends correct HTTP request', async () => { const { mockHttpClient, endpoint } = createEndpointTestSetup(); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockEmptyResponse)); - try { - const result = await endpoint.delete('test-group-123'); - - // Verify HTTP client was called correctly + const response = await endpoint.delete('test-group-123'); expect(sendStub.calls.length).toBe(1); - const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('DELETE'); expect(sentRequest.path).toBe('/groups/test-group-123'); expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups/test-group-123'); expect(sentRequest.query).toBeUndefined(); expect(sentRequest.body).toBeUndefined(); - - // Verify response - expect(result).toEqual(mockEmptyResponse); + expect(response).toEqual(mockEmptyResponse); } finally { sendStub.restore(); } @@ -283,13 +277,19 @@ Deno.test('GroupsEndpoint', async (t) => { hostname: 'https://oauth.xmatters.com', }); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); - try { - await endpoint.getGroups(); - + const response = await endpoint.getGroups(); + expect(sendStub.calls.length).toBe(1); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; - expect(sentRequest.headers?.['Authorization']).toBe('Bearer test-access-token'); + expect(sentRequest.method).toBe('GET'); + expect(sentRequest.path).toBe('/groups'); expect(sentRequest.url).toBe('https://oauth.xmatters.com/api/xm/1/groups'); + expect(sentRequest.query).toBeUndefined(); + expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); + expect(sentRequest.headers?.['Accept']).toBe('application/json'); + expect(sentRequest.headers?.['Authorization']).toBe('Bearer test-access-token'); + expect(sentRequest.body).toBeUndefined(); + expect(response).toEqual(mockPaginatedResponse); } finally { sendStub.restore(); } @@ -303,7 +303,6 @@ Deno.test('GroupsEndpoint', async (t) => { body: { message: 'Group not found' }, }; const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(errorResponse)); - try { let thrownError: unknown; try { @@ -311,8 +310,6 @@ Deno.test('GroupsEndpoint', async (t) => { } catch (error) { thrownError = error; } - - // Verify error was thrown expect(thrownError).toBeInstanceOf(XmApiError); const xmError = thrownError as XmApiError; expect(xmError.message).toBe('Group not found'); // Uses the message from response body @@ -327,7 +324,6 @@ Deno.test('GroupsEndpoint', async (t) => { const { mockHttpClient, endpoint } = createEndpointTestSetup(); const networkError = new Error('Network connection failed'); const sendStub = stub(mockHttpClient, 'send', () => Promise.reject(networkError)); - try { let thrownError: unknown; try { @@ -335,8 +331,6 @@ Deno.test('GroupsEndpoint', async (t) => { } catch (error) { thrownError = error; } - - // Verify error was thrown and wrapped expect(thrownError).toBeInstanceOf(XmApiError); const xmError = thrownError as XmApiError; expect(xmError.message).toBe('Request failed'); // Generic message for network errors @@ -351,12 +345,19 @@ Deno.test('GroupsEndpoint', async (t) => { hostname: 'https://custom.xmatters.com', }); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); - try { - await endpoint.getGroups(); - + const response = await endpoint.getGroups(); + expect(sendStub.calls.length).toBe(1); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; + expect(sentRequest.method).toBe('GET'); + expect(sentRequest.path).toBe('/groups'); expect(sentRequest.url).toBe('https://custom.xmatters.com/api/xm/1/groups'); + expect(sentRequest.query).toBeUndefined(); + expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); + expect(sentRequest.headers?.['Accept']).toBe('application/json'); + expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); + expect(sentRequest.body).toBeUndefined(); + expect(response).toEqual(mockPaginatedResponse); } finally { sendStub.restore(); } @@ -368,17 +369,26 @@ Deno.test('GroupsEndpoint', async (t) => { password: 'testpass', }); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); - try { - await endpoint.getGroups(); - + const response = await endpoint.getGroups(); + expect(sendStub.calls.length).toBe(1); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; + expect(sentRequest.method).toBe('GET'); + expect(sentRequest.path).toBe('/groups'); + expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups'); + expect(sentRequest.query).toBeUndefined(); + expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); + expect(sentRequest.headers?.['Accept']).toBe('application/json'); + // Verify the basic auth header + expect(sentRequest.headers?.['Authorization']).toBeDefined(); + expect(sentRequest.headers?.['Authorization']).toBe('Basic ' + btoa('testuser:testpass')); expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); - // Verify the basic auth encoding const authPart = sentRequest.headers?.['Authorization']?.split(' ')[1]; const decoded = atob(authPart!); expect(decoded).toBe('testuser:testpass'); + expect(sentRequest.body).toBeUndefined(); + expect(response).toEqual(mockPaginatedResponse); } finally { sendStub.restore(); } @@ -387,7 +397,6 @@ Deno.test('GroupsEndpoint', async (t) => { await t.step('save() with full group object - sends all fields correctly', async () => { const { mockHttpClient, endpoint } = createEndpointTestSetup(); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockSingleGroupResponse)); - const fullGroup: Partial = { id: 'existing-group-123', targetName: 'Updated Group', @@ -397,14 +406,19 @@ Deno.test('GroupsEndpoint', async (t) => { description: 'Updated test group', supervisors: ['user1', 'user2'], }; - try { - await endpoint.save(fullGroup); - + const response = await endpoint.save(fullGroup); + expect(sendStub.calls.length).toBe(1); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('POST'); - expect(sentRequest.body).toEqual(fullGroup); + expect(sentRequest.path).toBe('/groups'); + expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups'); + expect(sentRequest.query).toBeUndefined(); expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); + expect(sentRequest.headers?.['Accept']).toBe('application/json'); + expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); + expect(sentRequest.body).toEqual(fullGroup); + expect(response).toEqual(mockSingleGroupResponse); } finally { sendStub.restore(); } @@ -413,19 +427,27 @@ Deno.test('GroupsEndpoint', async (t) => { await t.step('getGroups() with all possible parameters', async () => { const { mockHttpClient, endpoint } = createEndpointTestSetup(); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); - const params = { limit: 25, offset: 50, search: 'test search', // Add other params that might exist in GetGroupsParams }; - try { - await endpoint.getGroups(params); - + const response = await endpoint.getGroups(params); + expect(sendStub.calls.length).toBe(1); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; + expect(sentRequest.method).toBe('GET'); + expect(sentRequest.path).toBe('/groups'); + expect(sentRequest.url).toBe( + 'https://example.xmatters.com/api/xm/1/groups?limit=25&offset=50&search=test+search', + ); expect(sentRequest.query).toEqual(params); + expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); + expect(sentRequest.headers?.['Accept']).toBe('application/json'); + expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); + expect(sentRequest.body).toBeUndefined(); + expect(response).toEqual(mockPaginatedResponse); } finally { sendStub.restore(); } @@ -433,10 +455,8 @@ Deno.test('GroupsEndpoint', async (t) => { await t.step('retries on rate limit with Retry-After', async () => { const fakeTime = new FakeTime(); - try { - const { mockHttpClient, endpoint } = createEndpointTestSetup(); - + const { mockHttpClient, mockLogger, endpoint } = createEndpointTestSetup(); const rateLimitResponse = { status: 429, headers: { 'retry-after': '2', 'content-type': 'application/json' }, @@ -447,7 +467,6 @@ Deno.test('GroupsEndpoint', async (t) => { headers: { 'content-type': 'application/json' }, body: { count: 2, total: 10, data: mockGroupsList }, }; - let callCount = 0; const sendStub = stub(mockHttpClient, 'send', () => { callCount++; @@ -455,28 +474,28 @@ Deno.test('GroupsEndpoint', async (t) => { ? Promise.resolve(rateLimitResponse) : Promise.resolve(successResponse); }); - + const loggerStub = stub(mockLogger, 'debug', () => {}); try { // Start the request const requestPromise = endpoint.getGroups(); - // Allow the first request to complete and set up the timer await fakeTime.nextAsync(); // Now advance time to trigger the retry await fakeTime.nextAsync(); - const response = await requestPromise; expect(response.body).toEqual(successResponse.body); expect(sendStub.calls.length).toBe(2); - // Verify both calls were GET requests to /groups const firstRequest: HttpRequest = sendStub.calls[0].args[0]; expect(firstRequest.method).toBe('GET'); expect(firstRequest.path).toBe('/groups'); - const retryRequest: HttpRequest = sendStub.calls[1].args[0]; expect(retryRequest.method).toBe('GET'); expect(retryRequest.path).toBe('/groups'); + expect(loggerStub.calls.length).toBe(1); + expect(loggerStub.calls[0].args[0]).toBe( + 'Request failed with status 429, retrying in 2000ms (attempt 1/3)', + ); } finally { sendStub.restore(); } @@ -487,17 +506,14 @@ Deno.test('GroupsEndpoint', async (t) => { await t.step('handles errors without response body', async () => { const { mockHttpClient, endpoint } = createEndpointTestSetup(); - const errorResponse = { status: 400, // Use 400 instead of 502 to avoid retry logic headers: { 'content-type': 'text/plain' }, body: '', // Empty response body }; - const sendStub = stub(mockHttpClient, 'send', () => { return Promise.resolve(errorResponse); }); - try { let thrownError: unknown; try { @@ -505,18 +521,13 @@ Deno.test('GroupsEndpoint', async (t) => { } catch (error) { thrownError = error; } - - // Verify error was thrown + // Verify it was called only once (no retries for 400) + expect(sendStub.calls.length).toBe(1); expect(thrownError).toBeInstanceOf(XmApiError); const xmError = thrownError as XmApiError; - - // Verify fallback error message includes status code - expect(xmError.message).toContain('400'); + expect(xmError.message).toBe('Request failed with status 400'); expect(xmError.response?.status).toBe(400); expect(xmError.response?.body).toBe(''); - - // Verify it was called only once (no retries for 400) - expect(sendStub.calls.length).toBe(1); } finally { sendStub.restore(); } From 97b05ea5acf0f87daf1acdf47017281bb877f437 Mon Sep 17 00:00:00 2001 From: johan Date: Sun, 8 Jun 2025 01:47:27 -0700 Subject: [PATCH 018/101] Continue unit test refactoring --- src/core/request-builder.test.ts | 307 +++++++++++++---- src/core/request-handler.test.ts | 548 ++++++++++++++++++------------- 2 files changed, 575 insertions(+), 280 deletions(-) diff --git a/src/core/request-builder.test.ts b/src/core/request-builder.test.ts index 95f5141..77cfd8c 100644 --- a/src/core/request-builder.test.ts +++ b/src/core/request-builder.test.ts @@ -1,86 +1,273 @@ -import { assertEquals, assertThrows } from 'https://deno.land/std@0.193.0/testing/asserts.ts'; -import { RequestBuilder } from './request-builder.ts'; +import { expect } from 'https://deno.land/std@0.224.0/expect/mod.ts'; +import { RequestBuilder, RequestBuildOptions } from './request-builder.ts'; + +// Test helper to create RequestBuilder with standard configuration +function createRequestBuilderTestSetup(options: { + hostname?: string; + defaultHeaders?: Record; +} = {}) { + const { + hostname = 'https://example.xmatters.com', + defaultHeaders = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'default-header': 'default-value', + }, + } = options; + + const builder = new RequestBuilder(hostname, defaultHeaders); + + return { builder }; +} + +// Mock data for tests +const mockRelativePathOptions: RequestBuildOptions = { + path: '/people', + method: 'GET', + query: { search: 'test', limit: 10 }, +}; + +const mockExternalUrlOptions: RequestBuildOptions = { + fullUrl: 'https://api.external-service.com/v2/endpoint', + method: 'POST', + query: { key: 'value' }, + headers: { 'Authorization': 'Bearer token' }, +}; + +const mockCustomHeadersOptions: RequestBuildOptions = { + path: '/groups', + method: 'PUT', + headers: { + 'custom-header': 'custom-value', + 'default-header': 'overridden-value', // Should override default + }, + body: { name: 'test-group' }, +}; Deno.test('RequestBuilder', async (t) => { - const builder = new RequestBuilder('https://example.com', { - 'default-header': 'value', + await t.step('builds request with relative path - verifies correct URL construction', () => { + const { builder } = createRequestBuilderTestSetup(); + const request = builder.build(mockRelativePathOptions); + expect(request.url).toBe('https://example.xmatters.com/api/xm/1/people?search=test&limit=10'); + expect(request.path).toBe('/people'); + expect(request.method).toBe('GET'); + expect(request.headers?.['Content-Type']).toBe('application/json'); + expect(request.headers?.['Accept']).toBe('application/json'); + expect(request.headers?.['default-header']).toBe('default-value'); + expect(request.query).toEqual({ search: 'test', limit: 10 }); + expect(request.retryAttempt).toBe(0); }); - await t.step('builds request with relative path', () => { - const request = builder.build({ - path: '/people', - method: 'GET', - query: { search: 'test' }, - }); + await t.step('builds request with external URL - bypasses API version path', () => { + const { builder } = createRequestBuilderTestSetup(); + const request = builder.build(mockExternalUrlOptions); + expect(request.url).toBe('https://api.external-service.com/v2/endpoint?key=value'); + expect(request.path).toBe('https://api.external-service.com/v2/endpoint'); + expect(request.method).toBe('POST'); + expect(request.headers?.['Content-Type']).toBe('application/json'); + expect(request.headers?.['Accept']).toBe('application/json'); + expect(request.headers?.['Authorization']).toBe('Bearer token'); + expect(request.query).toEqual({ key: 'value' }); + }); - assertEquals(request.url, 'https://example.com/api/xm/1/people?search=test'); - assertEquals(request.path, '/people'); - assertEquals(request.method, 'GET'); - assertEquals(request.headers?.['default-header'], 'value'); + await t.step('preserves existing query parameters in external URLs', () => { + const { builder } = createRequestBuilderTestSetup(); + const options: RequestBuildOptions = { + fullUrl: 'https://api.external-service.com/search?existing=param&another=value', + query: { additional: 'param', new: 'value' }, + }; + const request = builder.build(options); + const url = new URL(request.url); + expect(url.searchParams.get('existing')).toBe('param'); + expect(url.searchParams.get('another')).toBe('value'); + expect(url.searchParams.get('additional')).toBe('param'); + expect(url.searchParams.get('new')).toBe('value'); + expect(request.path).toBe( + 'https://api.external-service.com/search?existing=param&another=value', + ); }); - await t.step('builds request with external URL', () => { - const request = builder.build({ - fullUrl: 'https://api.external-service.com/v2/endpoint', - method: 'POST', - query: { key: 'value' }, - }); + await t.step('merges headers correctly - request headers override defaults', () => { + const { builder } = createRequestBuilderTestSetup(); + const request = builder.build(mockCustomHeadersOptions); + expect(request.headers?.['Content-Type']).toBe('application/json'); + expect(request.headers?.['Accept']).toBe('application/json'); + expect(request.headers?.['default-header']).toBe('overridden-value'); // Overridden + expect(request.headers?.['custom-header']).toBe('custom-value'); // Added + expect(request.method).toBe('PUT'); + expect(request.body).toEqual({ name: 'test-group' }); + }); - assertEquals(request.url, 'https://api.external-service.com/v2/endpoint?key=value'); - assertEquals(request.path, 'https://api.external-service.com/v2/endpoint'); - assertEquals(request.method, 'POST'); - assertEquals(request.headers?.['default-header'], 'value'); + await t.step('defaults method to GET when not specified', () => { + const { builder } = createRequestBuilderTestSetup(); + const options: RequestBuildOptions = { + path: '/users', + }; + const request = builder.build(options); + expect(request.method).toBe('GET'); + expect(request.path).toBe('/users'); + expect(request.url).toBe('https://example.xmatters.com/api/xm/1/users'); }); - await t.step('preserves query parameters in external URLs', () => { - const request = builder.build({ - fullUrl: 'https://api.external-service.com/search?existing=param', - query: { additional: 'param' }, - }); + await t.step('handles empty query object', () => { + const { builder } = createRequestBuilderTestSetup(); + const options: RequestBuildOptions = { + path: '/devices', + query: {}, + }; + const request = builder.build(options); + expect(request.url).toBe('https://example.xmatters.com/api/xm/1/devices'); + expect(request.query).toEqual({}); + }); + await t.step('filters out null and undefined query parameters', () => { + const { builder } = createRequestBuilderTestSetup(); + const options: RequestBuildOptions = { + path: '/events', + query: { + status: 'active', + priority: null, + assignee: undefined, + limit: 25, + }, + }; + const request = builder.build(options); const url = new URL(request.url); - assertEquals(url.searchParams.get('existing'), 'param'); - assertEquals(url.searchParams.get('additional'), 'param'); + expect(url.searchParams.get('status')).toBe('active'); + expect(url.searchParams.get('limit')).toBe('25'); + expect(url.searchParams.has('priority')).toBe(false); + expect(url.searchParams.has('assignee')).toBe(false); }); - await t.step('throws when path does not start with slash', () => { - assertThrows( - () => builder.build({ path: 'people' }), - Error, - 'Path must start with a forward slash', - ); + await t.step('works with custom hostname configuration', () => { + const { builder } = createRequestBuilderTestSetup({ + hostname: 'https://custom.xmatters.com', + }); + const options: RequestBuildOptions = { + path: '/notifications', + }; + const request = builder.build(options); + expect(request.url).toBe('https://custom.xmatters.com/api/xm/1/notifications'); + expect(request.path).toBe('/notifications'); }); - await t.step('throws when both path and fullUrl are provided', () => { - assertThrows( - () => - builder.build({ - path: '/people', - fullUrl: 'https://api.external-service.com/v2/endpoint', - }), - Error, - 'Cannot specify both fullUrl and path', - ); + await t.step('works with empty default headers', () => { + const { builder } = createRequestBuilderTestSetup({ + defaultHeaders: {}, + }); + const options: RequestBuildOptions = { + path: '/sites', + headers: { 'Custom-Header': 'value' }, + }; + const request = builder.build(options); + expect(request.headers).toEqual({ 'Custom-Header': 'value' }); + }); + + await t.step('preserves retry attempt when provided', () => { + const { builder } = createRequestBuilderTestSetup(); + const options: RequestBuildOptions = { + path: '/shifts', + retryAttempt: 2, + }; + const request = builder.build(options); + expect(request.retryAttempt).toBe(2); + }); + + await t.step('Error handling - throws when path does not start with slash', () => { + const { builder } = createRequestBuilderTestSetup(); + let thrownError: unknown; + try { + builder.build({ path: 'people' }); + } catch (error) { + thrownError = error; + } + expect(thrownError).toBeInstanceOf(Error); + const error = thrownError as Error; + expect(error.message).toBe('Path must start with a forward slash, e.g. "/people"'); }); - await t.step('throws when neither path nor fullUrl is provided', () => { - assertThrows( - () => builder.build({}), - Error, - 'Either path or fullUrl must be provided', + await t.step('Error handling - throws when both path and fullUrl are provided', () => { + const { builder } = createRequestBuilderTestSetup(); + let thrownError: unknown; + try { + builder.build({ + path: '/people', + fullUrl: 'https://api.external-service.com/v2/endpoint', + }); + } catch (error) { + thrownError = error; + } + expect(thrownError).toBeInstanceOf(Error); + const error = thrownError as Error; + expect(error.message).toBe( + 'Cannot specify both fullUrl and path. Use fullUrl for external endpoints, path for xMatters API endpoints.', ); }); - await t.step('merges headers correctly', () => { - const request = builder.build({ - path: '/people', + await t.step('Error handling - throws when neither path nor fullUrl is provided', () => { + const { builder } = createRequestBuilderTestSetup(); + let thrownError: unknown; + try { + builder.build({}); + } catch (error) { + thrownError = error; + } + expect(thrownError).toBeInstanceOf(Error); + const error = thrownError as Error; + expect(error.message).toBe('Either path or fullUrl must be provided'); + }); + + await t.step('builds complex request with all options', () => { + const { builder } = createRequestBuilderTestSetup(); + const complexOptions: RequestBuildOptions = { + path: '/forms/abc123/submissions', + method: 'PATCH', + query: { + status: 'pending', + priority: 'high', + assignee: 'user123', + }, headers: { - 'custom-header': 'custom-value', - 'default-header': 'overridden-value', // Should override default + 'Authorization': 'Bearer access-token', + 'X-Custom-Header': 'custom-value', + 'Content-Type': 'application/vnd.api+json', // Override default }, + body: { + data: { + type: 'form-submission', + attributes: { + status: 'reviewed', + comments: 'Looks good', + }, + }, + }, + retryAttempt: 1, + }; + const request = builder.build(complexOptions); + expect(request.url).toBe( + 'https://example.xmatters.com/api/xm/1/forms/abc123/submissions?status=pending&priority=high&assignee=user123', + ); + expect(request.path).toBe('/forms/abc123/submissions'); + expect(request.method).toBe('PATCH'); + expect(request.query).toEqual({ + status: 'pending', + priority: 'high', + assignee: 'user123', }); - - assertEquals(request.headers?.['default-header'], 'overridden-value'); - assertEquals(request.headers?.['custom-header'], 'custom-value'); + expect(request.headers?.['Authorization']).toBe('Bearer access-token'); + expect(request.headers?.['X-Custom-Header']).toBe('custom-value'); + expect(request.headers?.['Content-Type']).toBe('application/vnd.api+json'); + expect(request.headers?.['Accept']).toBe('application/json'); + expect(request.headers?.['default-header']).toBe('default-value'); + expect(request.body).toEqual({ + data: { + type: 'form-submission', + attributes: { + status: 'reviewed', + comments: 'Looks good', + }, + }, + }); + expect(request.retryAttempt).toBe(1); }); }); diff --git a/src/core/request-handler.test.ts b/src/core/request-handler.test.ts index 5534cfd..30d12dd 100644 --- a/src/core/request-handler.test.ts +++ b/src/core/request-handler.test.ts @@ -1,281 +1,389 @@ -import { - assertEquals, - assertExists, - assertRejects, -} from 'https://deno.land/std@0.193.0/testing/asserts.ts'; +/** + * @fileoverview Test suite for RequestHandler class + * + * This test file follows the established patterns from the groups endpoint test: + * - Uses expect assertions for consistency across the codebase + * - Implements test setup helpers for creating mock dependencies + * - Uses try/finally blocks to ensure proper cleanup of stubs + * - Creates mock data objects for reusable test responses + * - Follows descriptive test step naming conventions + * - Tests both success and error scenarios comprehensively + */ + +import { expect } from 'https://deno.land/std@0.224.0/expect/mod.ts'; +import { FakeTime } from 'https://deno.land/std@0.224.0/testing/time.ts'; import { RequestHandler } from './request-handler.ts'; -import { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; +import type { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; +import type { Logger, XmApiOptions } from './types/internal/config.ts'; +import type { TokenState } from './types/internal/oauth.ts'; import { XmApiError } from './errors.ts'; -class TestHttpClient implements HttpClient { +/** + * Mock HTTP client that can simulate sequential responses for testing retry logic + */ +class MockHttpClient implements HttpClient { + private responses: HttpResponse[] = []; + private callIndex = 0; public requests: HttpRequest[] = []; - public responses: HttpResponse[] = []; - public forceError?: Error; + + constructor(responses: HttpResponse[]) { + this.responses = responses; + } send(request: HttpRequest): Promise { this.requests.push(request); - if (this.forceError) { - return Promise.reject(this.forceError); - } - return Promise.resolve( - this.responses[this.requests.length - 1] || this.responses[this.responses.length - 1], - ); + const response = this.responses[this.callIndex] || this.responses[this.responses.length - 1]; + this.callIndex++; + return Promise.resolve(response); + } + + reset() { + this.callIndex = 0; + this.requests = []; + } +} + +/** + * Test helper to create RequestHandler test setup + */ +function createRequestHandlerTestSetup(options: { + hostname?: string; + username?: string; + password?: string; + accessToken?: string; + refreshToken?: string; + clientId?: string; + maxRetries?: number; + responses?: HttpResponse[]; +} = {}) { + const { + hostname = 'https://example.xmatters.com', + username = 'testuser', + password = 'password123', + accessToken, + refreshToken, + clientId, + maxRetries = 3, + responses = [mockSuccessResponse], + } = options; + + // Create silent mock logger + const mockLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }; + + // Create auth options based on provided parameters + const mockOptions: XmApiOptions = accessToken + ? { hostname, accessToken, refreshToken, clientId } + : { hostname, username, password }; + + const mockHttpClient = new MockHttpClient(responses); + + // Create token state for OAuth options if needed + let tokenState: TokenState | undefined; + if (accessToken) { + tokenState = { + accessToken, + refreshToken: refreshToken || '', + clientId, + expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), // 5 minutes from now + scopes: [], + }; } + + const requestHandler = new RequestHandler( + mockHttpClient, + mockLogger, + mockOptions, + maxRetries, + undefined, // onTokenRefresh callback + tokenState, + ); + + return { mockHttpClient, requestHandler, mockLogger }; } -const mockLogger = { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, +// Mock response data for tests +const mockSuccessResponse: HttpResponse = { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { success: true }, +}; + +const mockErrorResponse: HttpResponse = { + status: 400, + headers: { 'content-type': 'text/plain' }, + body: 'Invalid request', +}; + +const mockRateLimitResponse: HttpResponse = { + status: 429, + headers: { 'retry-after': '1' }, + body: { message: 'Too many requests' }, +}; + +const mockServerErrorResponse: HttpResponse = { + status: 503, + headers: {}, + body: { message: 'Service unavailable' }, }; -const basicOptions = { - hostname: 'https://example.com', - username: 'testuser', - password: 'password123', - defaultHeaders: {}, +const mockUnauthorizedResponse: HttpResponse = { + status: 401, + headers: {}, + body: { message: 'Token expired' }, }; -const oauthOptions = { - hostname: 'https://example.com', - accessToken: 'access-token', - refreshToken: 'refresh-token', - clientId: 'client-id', - defaultHeaders: {}, +const mockTokenRefreshResponse: HttpResponse = { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { + access_token: 'new-token', + refresh_token: 'new-refresh-token', + expires_in: 3600, + }, }; Deno.test('RequestHandler', async (t) => { await t.step('handles non-JSON response bodies', async () => { - const client = new TestHttpClient(); - const handler = new RequestHandler(client, mockLogger, basicOptions, 3); - - client.responses = [{ - status: 400, - headers: { 'content-type': 'text/plain' }, - body: 'Invalid request', - }]; - - await assertRejects( - async () => await handler.get({ path: '/test' }), - XmApiError, - 'Invalid request', - ); + const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup({ + responses: [mockErrorResponse], + }); + + try { + let thrownError: unknown; + try { + await requestHandler.get({ path: '/test' }); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(XmApiError); + const xmError = thrownError as XmApiError; + expect(xmError.message).toBe('Invalid request'); + expect(mockHttpClient.requests.length).toBe(1); + } finally { + mockHttpClient.reset(); + } }); await t.step('retries on rate limit with Retry-After', async () => { - const client = new TestHttpClient(); - const handler = new RequestHandler(client, mockLogger, basicOptions, 3); - - client.responses = [ - { - status: 429, - headers: { 'retry-after': '1' }, - body: { message: 'Too many requests' }, - }, - { - status: 200, - headers: { 'content-type': 'application/json' }, - body: { success: true }, - }, - ]; - - const response = await handler.get({ path: '/test' }); - assertEquals(response.status, 200); - assertEquals(client.requests.length, 2); - assertEquals(client.requests[1].retryAttempt, 1); + const fakeTime = new FakeTime(); + try { + const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup({ + responses: [mockRateLimitResponse, mockSuccessResponse], + }); + + const requestPromise = requestHandler.get({ path: '/test' }); + + // Allow the first request to complete and set up the timer + await fakeTime.nextAsync(); + // Now advance time to trigger the retry + await fakeTime.nextAsync(); + + const response = await requestPromise; + + expect(response.status).toBe(200); + expect(mockHttpClient.requests.length).toBe(2); + + // Verify first request + const firstRequest = mockHttpClient.requests[0]; + expect(firstRequest.path).toBe('/test'); + expect(firstRequest.retryAttempt).toBe(0); + + // Verify retry request + const retryRequest = mockHttpClient.requests[1]; + expect(retryRequest.path).toBe('/test'); + expect(retryRequest.retryAttempt).toBe(1); + } finally { + fakeTime.restore(); + } }); await t.step('retries with exponential backoff on server error', async () => { - const client = new TestHttpClient(); - const handler = new RequestHandler(client, mockLogger, basicOptions, 3); - - client.responses = [ - { - status: 503, - headers: {}, - body: { message: 'Service unavailable' }, - }, - { - status: 200, - headers: { 'content-type': 'application/json' }, - body: { success: true }, - }, - ]; - - const response = await handler.get({ path: '/test' }); - assertEquals(response.status, 200); - assertEquals(client.requests.length, 2); - assertEquals(client.requests[1].retryAttempt, 1); - }); + const fakeTime = new FakeTime(); + try { + const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup({ + responses: [mockServerErrorResponse, mockSuccessResponse], + }); - await t.step('stops retrying after max attempts', async () => { - const client = new TestHttpClient(); - const handler = new RequestHandler(client, mockLogger, basicOptions, 3); + const requestPromise = requestHandler.get({ path: '/test' }); - client.responses = Array(5).fill({ - status: 503, - headers: {}, - body: { message: 'Service unavailable' }, - }); + // Allow the first request to complete and set up the timer + await fakeTime.nextAsync(); + // Now advance time to trigger the retry + await fakeTime.nextAsync(); - await assertRejects( - async () => await handler.get({ path: '/test' }), - XmApiError, - 'Service unavailable', - ); + const response = await requestPromise; + + expect(response.status).toBe(200); + expect(mockHttpClient.requests.length).toBe(2); - assertEquals(client.requests.length, 4); // Initial + 3 retries + // Verify retry attempt increments + const retryRequest = mockHttpClient.requests[1]; + expect(retryRequest.retryAttempt).toBe(1); + } finally { + fakeTime.restore(); + } + }); + + await t.step('stops retrying after max attempts', async () => { + const fakeTime = new FakeTime(); + try { + const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup({ + maxRetries: 3, // Use full retry count to properly test the behavior + responses: [mockServerErrorResponse], // Will repeat the error response + }); + + let thrownError: unknown; + const requestPromise = requestHandler.get({ path: '/test' }).catch((error) => { + thrownError = error; + }); + + // Allow all request attempts and retries to complete + // The pattern is: request -> setTimeout -> retry -> setTimeout -> retry -> etc. + await fakeTime.nextAsync(); // First request completes, setTimeout for retry 1 + await fakeTime.nextAsync(); // Retry 1 completes, setTimeout for retry 2 + await fakeTime.nextAsync(); // Retry 2 completes, setTimeout for retry 3 + await fakeTime.nextAsync(); // Retry 3 completes, should throw error + + await requestPromise; + + expect(thrownError).toBeInstanceOf(XmApiError); + const xmError = thrownError as XmApiError; + expect(xmError.message).toBe('Service unavailable'); + expect(mockHttpClient.requests.length).toBe(4); // Initial + 3 retries (maxRetries=3) + + // Verify each request has the correct retry attempt number + expect(mockHttpClient.requests[0].retryAttempt).toBe(0); + expect(mockHttpClient.requests[1].retryAttempt).toBe(1); + expect(mockHttpClient.requests[2].retryAttempt).toBe(2); + expect(mockHttpClient.requests[3].retryAttempt).toBe(3); + + mockHttpClient.reset(); + } finally { + fakeTime.restore(); + } }); await t.step('handles network errors', async () => { - const client = new TestHttpClient(); - const handler = new RequestHandler(client, mockLogger, basicOptions, 3); + const { mockHttpClient: _mockHttpClient } = createRequestHandlerTestSetup(); + + // Create a separate mock client that throws network errors + const mockHttpClient: HttpClient = { + send: () => Promise.reject(new Error('Network error')), + }; - client.forceError = new Error('Network error'); + const networkRequestHandler = new RequestHandler( + mockHttpClient, + { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, + { hostname: 'https://example.xmatters.com', username: 'test', password: 'test' }, + 3, + ); try { - await handler.get({ path: '/test' }); - throw new Error('Expected error to be thrown'); - } catch (error: unknown) { - assertExists(error); - assertEquals(error instanceof XmApiError, true); - const xmError = error as XmApiError; - assertEquals(xmError.message, 'Request failed'); - assertEquals(xmError.response, undefined); - assertEquals(xmError.cause instanceof Error, true); - assertEquals((xmError.cause as Error).message, 'Network error'); + let thrownError: unknown; + try { + await networkRequestHandler.get({ path: '/test' }); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(XmApiError); + const xmError = thrownError as XmApiError; + expect(xmError.message).toBe('Request failed'); + expect(xmError.response).toBeUndefined(); + expect(xmError.cause).toBeInstanceOf(Error); + expect((xmError.cause as Error).message).toBe('Network error'); + } finally { + // No cleanup needed for this test } }); await t.step('adds Basic Auth header to requests', async () => { - const client = new TestHttpClient(); - const handler = new RequestHandler(client, mockLogger, basicOptions, 3); - - client.responses = [{ - status: 200, - headers: { 'content-type': 'application/json' }, - body: { success: true }, - }]; - - await handler.get({ path: '/test' }); + const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup(); - assertEquals(client.requests.length, 1); - const request = client.requests[0]; - assertExists(request.headers?.Authorization); - - // Verify it's Basic auth - const [authType] = request.headers.Authorization.split(' '); - assertEquals(authType, 'Basic'); + try { + await requestHandler.get({ path: '/test' }); + + expect(mockHttpClient.requests.length).toBe(1); + const sentRequest = mockHttpClient.requests[0]; + expect(sentRequest.headers?.Authorization).toBeDefined(); + + // Verify it's Basic auth + const authHeader = sentRequest.headers!.Authorization!; + const [authType] = authHeader.split(' '); + expect(authType).toBe('Basic'); + } finally { + mockHttpClient.reset(); + } }); await t.step('adds OAuth Bearer token to requests', async () => { - const client = new TestHttpClient(); - const initialTokenState = { + const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup({ accessToken: 'test-access-token', refreshToken: 'test-refresh-token', clientId: 'client-id', - expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), - scopes: [], - }; - const handler = new RequestHandler( - client, - mockLogger, - oauthOptions, - 3, - undefined, - initialTokenState, - ); - - client.responses = [{ - status: 200, - headers: { 'content-type': 'application/json' }, - body: { success: true }, - }]; + }); - await handler.get({ path: '/test' }); + try { + await requestHandler.get({ path: '/test' }); - assertEquals(client.requests.length, 1); - const request = client.requests[0]; - assertEquals(request.headers?.Authorization, 'Bearer test-access-token'); + expect(mockHttpClient.requests.length).toBe(1); + const sentRequest = mockHttpClient.requests[0]; + expect(sentRequest.headers?.Authorization).toBe('Bearer test-access-token'); + } finally { + mockHttpClient.reset(); + } }); await t.step('refreshes token on 401 response', async () => { - const client = new TestHttpClient(); - const initialTokenState = { + const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup({ accessToken: 'old-token', refreshToken: 'refresh-token', clientId: 'client-id', - expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), - scopes: [], - }; - const handler = new RequestHandler( - client, - mockLogger, - oauthOptions, - 3, - undefined, - initialTokenState, - ); + responses: [mockUnauthorizedResponse, mockTokenRefreshResponse, mockSuccessResponse], + }); - client.responses = [ - // Initial request fails with 401 - { - status: 401, - headers: {}, - body: { message: 'Token expired' }, - }, - // Token refresh succeeds - { - status: 200, - headers: { 'content-type': 'application/json' }, - body: { - access_token: 'new-token', - refresh_token: 'new-refresh-token', - expires_in: 3600, - }, - }, - // Original request succeeds with new token - { - status: 200, - headers: {}, - body: { success: true }, - }, - ]; - - const response = await handler.get({ path: '/test' }); - assertEquals(response.status, 200); - assertEquals(client.requests.length, 3); - - // Verify token refresh request - const refreshRequest = client.requests[1]; - assertEquals(refreshRequest.path, '/oauth2/token'); - assertEquals(refreshRequest.headers?.['Content-Type'], 'application/x-www-form-urlencoded'); - assertExists(refreshRequest.body); - const params = new URLSearchParams(refreshRequest.body as string); - assertEquals(params.get('grant_type'), 'refresh_token'); - assertEquals(params.get('refresh_token'), 'refresh-token'); - assertEquals(params.get('client_id'), 'client-id'); - - // Verify retried request uses new token - const retriedRequest = client.requests[2]; - assertEquals(retriedRequest.headers?.Authorization, 'Bearer new-token'); + try { + const response = await requestHandler.get({ path: '/test' }); + + expect(response.status).toBe(200); + expect(mockHttpClient.requests.length).toBe(3); + + // Verify token refresh request + const refreshRequest = mockHttpClient.requests[1]; + expect(refreshRequest.path).toBe('/oauth2/token'); + expect(refreshRequest.headers?.['Content-Type']).toBe('application/x-www-form-urlencoded'); + expect(refreshRequest.body).toBeDefined(); + + const params = new URLSearchParams(refreshRequest.body as string); + expect(params.get('grant_type')).toBe('refresh_token'); + expect(params.get('refresh_token')).toBe('refresh-token'); + expect(params.get('client_id')).toBe('client-id'); + + // Verify retried request uses new token + const retriedRequest = mockHttpClient.requests[2]; + expect(retriedRequest.headers?.Authorization).toBe('Bearer new-token'); + } finally { + mockHttpClient.reset(); + } }); await t.step('skips auth headers when skipAuth is true', async () => { - const client = new TestHttpClient(); - const handler = new RequestHandler(client, mockLogger, basicOptions, 3); - - client.responses = [{ - status: 200, - headers: { 'content-type': 'application/json' }, - body: { success: true }, - }]; + const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup(); - await handler.send({ path: '/oauth2/token', skipAuth: true }); + try { + await requestHandler.send({ path: '/oauth2/token', skipAuth: true }); - assertEquals(client.requests.length, 1); - const request = client.requests[0]; - assertEquals(request.headers?.Authorization, undefined); + expect(mockHttpClient.requests.length).toBe(1); + const sentRequest = mockHttpClient.requests[0]; + expect(sentRequest.headers?.Authorization).toBeUndefined(); + } finally { + mockHttpClient.reset(); + } }); }); From f3c8fe8b3b1537ce15749c4754c840e877d0f6fe Mon Sep 17 00:00:00 2001 From: johan Date: Sun, 8 Jun 2025 12:30:06 -0700 Subject: [PATCH 019/101] Remove bloat that was bothering me --- src/core/errors.ts | 4 +- src/core/request-handler.ts | 40 +------ src/core/test-utils.ts | 173 ----------------------------- src/endpoints/groups/index.test.ts | 2 +- 4 files changed, 7 insertions(+), 212 deletions(-) delete mode 100644 src/core/test-utils.ts diff --git a/src/core/errors.ts b/src/core/errors.ts index 53a2420..83f38f6 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -11,8 +11,8 @@ export class XmApiError extends Error { constructor( message: string, public readonly response?: { - /** The response body as a string */ - body: string; + /** The response body in its original format */ + body: unknown; /** The HTTP status code that triggered this error */ status: number; /** Response headers that may contain additional error context */ diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index 3f9d081..a542032 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -16,38 +16,6 @@ export class RequestHandler { /** Request builder for creating HTTP requests */ private readonly requestBuilder: RequestBuilder; - /** - * Helper method to safely convert a response body to a string for error messages - */ - private stringifyErrorBody(body: unknown): string { - if (typeof body === 'string') { - return body; - } - if (body && typeof body === 'object') { - try { - return JSON.stringify(body); - } catch { - return '[Unable to stringify error body]'; - } - } - return String(body ?? '[No error details available]'); - } - - /** - * Helper method to create an error response - */ - private createErrorResponse(response: HttpResponse): { - body: string; - status: number; - headers: Record; - } { - return { - body: this.stringifyErrorBody(response.body), - status: response.status, - headers: response.headers, - }; - } - constructor( private readonly client: HttpClient, private readonly logger: Logger, @@ -110,13 +78,13 @@ export class RequestHandler { const response = await this.client.send(refreshRequest); if (response.status !== 200 || !response.body) { - throw new XmApiError('Failed to refresh token', this.createErrorResponse(response)); + throw new XmApiError('Failed to refresh token', response); } if (typeof response.body !== 'object' || !response.body) { throw new XmApiError( 'Invalid token response format', - this.createErrorResponse(response), + response, ); } @@ -130,7 +98,7 @@ export class RequestHandler { if (!body.access_token || !body.refresh_token) { throw new XmApiError( 'Token response missing required fields', - this.createErrorResponse(response), + response, ); } @@ -275,7 +243,7 @@ export class RequestHandler { throw new XmApiError( message, - this.createErrorResponse(response), + response, ); } diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts deleted file mode 100644 index 280b896..0000000 --- a/src/core/test-utils.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; -import { Logger, XmApiOptions } from './types/internal/config.ts'; -import { XmApiError } from './errors.ts'; -import { RequestHandler } from './request-handler.ts'; - -export class MockRequestHandler extends RequestHandler { - public readonly requests: HttpRequest[] = []; - private readonly responses: HttpResponse[]; - public forceError?: Error; - - constructor(responses: HttpResponse | HttpResponse[]) { - // Create minimal implementations for required dependencies - const mockClient: HttpClient = { - send: () => Promise.resolve({ status: 200, headers: {}, body: {} }), - }; - const mockLogger: Logger = { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - }; - const mockOptions: XmApiOptions = { - hostname: 'https://example.com', - username: 'test-user', - password: 'test-password', - }; - - super(mockClient, mockLogger, mockOptions); - this.responses = Array.isArray(responses) ? responses : [responses]; - } - - override async send( - request: Partial & { path: string; method?: HttpRequest['method'] }, - ): Promise> { - try { - const fullRequest: HttpRequest = { - method: request.method || 'GET', - path: request.path, - url: request.url || `https://example.com${request.path}`, - query: request.query, - headers: request.headers, - body: request.body, - retryAttempt: request.retryAttempt || 0, - }; - - this.requests.push(fullRequest); - - // If forceError is set, throw it as an XmApiError - if (this.forceError) { - throw new XmApiError('Request failed', undefined, this.forceError); - } - - const currentAttempt = fullRequest.retryAttempt ?? 0; - const currentResponse = this.responses[currentAttempt]; - if (!currentResponse) { - throw new XmApiError('No mock response available for request'); - } - - // For retryable responses (429 or 5xx), check if we have a next response - if ( - (currentResponse.status === 429 || - (currentResponse.status >= 500 && currentResponse.status < 600)) && - this.responses[currentAttempt + 1] - ) { - // For rate limits, respect the Retry-After header - if (currentResponse.status === 429 && currentResponse.headers['retry-after']) { - const retryAfter = parseInt(currentResponse.headers['retry-after'], 10); - if (!isNaN(retryAfter)) { - await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000)); - } - } - - // Retry the request with the next response - return this.send({ - ...request, - retryAttempt: currentAttempt + 1, - }); - } - - // For non-retryable errors or if we're out of responses, throw an error - if (currentResponse.status >= 400) { - let message = `Request failed with status ${currentResponse.status}`; - if ( - currentResponse.body && typeof currentResponse.body === 'object' && - 'message' in currentResponse.body - ) { - message = String(currentResponse.body.message); - } else if (typeof currentResponse.body === 'string' && currentResponse.body.trim()) { - message = currentResponse.body.trim(); - } - - if ( - currentResponse.body && typeof currentResponse.body === 'object' && - 'code' in currentResponse.body - ) { - message = `${currentResponse.body.code}: ${message}`; - } - - throw new XmApiError( - message, - { - status: currentResponse.status, - headers: currentResponse.headers, - body: typeof currentResponse.body === 'string' - ? currentResponse.body - : JSON.stringify(currentResponse.body), - }, - ); - } - - return currentResponse as HttpResponse; - } catch (error) { - if (error instanceof XmApiError) { - throw error; - } - throw new XmApiError('Request failed', undefined, error); - } - } -} - -export interface CreateMockResponseOptions { - /** The response body */ - body: T; - /** The HTTP status code */ - status?: number; - /** Response headers */ - headers?: Record; -} - -export function createMockResponse(options: CreateMockResponseOptions): HttpResponse { - return { - body: options.body, - status: options.status ?? 200, - headers: { - 'content-type': 'application/json', - ...options.headers, - }, - }; -} - -export interface CreateMockOptionsConfig { - /** Optional base URL for the mock API. Defaults to https://example.com */ - mockBaseUrl?: string; - /** OAuth configuration */ - oauth?: { - accessToken?: string; - refreshToken?: string; - clientId?: string; - }; - /** Basic Auth configuration */ - basicAuth?: { - username?: string; - password?: string; - }; -} - -export function createMockOptions(config: CreateMockOptionsConfig = {}): XmApiOptions { - const { mockBaseUrl = 'https://example.com' } = config; - - if (config.oauth) { - return { - hostname: mockBaseUrl, - accessToken: config.oauth.accessToken ?? 'mock-token', - refreshToken: config.oauth.refreshToken, - clientId: config.oauth.clientId, - }; - } - return { - hostname: mockBaseUrl, - username: config.basicAuth?.username ?? 'mock-user', - password: config.basicAuth?.password ?? 'mock-password', - }; -} diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index 8a7ecfb..f12f9cb 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -314,7 +314,7 @@ Deno.test('GroupsEndpoint', async (t) => { const xmError = thrownError as XmApiError; expect(xmError.message).toBe('Group not found'); // Uses the message from response body expect(xmError.response?.status).toBe(404); - expect(xmError.response?.body).toBe('{"message":"Group not found"}'); + expect(xmError.response?.body).toEqual({ message: 'Group not found' }); } finally { sendStub.restore(); } From c7d26b527b2931298f09cd189e76a37567eb0f58 Mon Sep 17 00:00:00 2001 From: johan Date: Sun, 8 Jun 2025 12:41:52 -0700 Subject: [PATCH 020/101] Remove the escape hatch from resource-clients.ts --- src/core/resource-client.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/core/resource-client.ts b/src/core/resource-client.ts index f88caeb..400d92e 100644 --- a/src/core/resource-client.ts +++ b/src/core/resource-client.ts @@ -33,19 +33,6 @@ export class ResourceClient { return `${this.basePath}/${cleanPath}`; } - /** - * Send a request with custom options. - * This is a low-level method that bypasses the automatic base path handling. - * Use this when you need complete control over the request path. - * - * @returns The full HTTP response - */ - send( - request: Partial & { path: string; method: HttpRequest['method'] }, - ) { - return this.http.send(request); - } - get(options: Omit & { path?: string }) { return this.http.get({ ...options, From 84db9c44c7c8786aa937eb4f0eed43a23e3bf499 Mon Sep 17 00:00:00 2001 From: johan Date: Sun, 8 Jun 2025 14:07:59 -0700 Subject: [PATCH 021/101] Refactor XmApiError --- src/core/errors.ts | 66 ++++++++++++++++++++++++++++++++++--- src/core/request-handler.ts | 19 +---------- 2 files changed, 63 insertions(+), 22 deletions(-) diff --git a/src/core/errors.ts b/src/core/errors.ts index 83f38f6..b3b367f 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -4,9 +4,19 @@ */ export class XmApiError extends Error { /** - * @param message Human-readable error message - * @param response Optional response details if the error occurred after receiving a response - * @param cause Optional underlying error that caused this error + * Creates an XmApiError instance. + * + * @param message Human-readable error message. If a response is provided, + * this will be overridden with a message extracted from the response body. + * @param response Optional HTTP response details when the error occurred after receiving a response. + * When provided, a more specific error message will be extracted from the response body. + * @param cause Optional underlying error that caused this XmApiError. + * Use this when wrapping lower-level errors (network errors, JSON parsing errors, etc.) + * to preserve the original error information. Maintainers should use this when: + * - Wrapping network/connection errors from the HTTP client + * - Wrapping JSON parsing errors + * - Wrapping any other system-level errors that should be preserved for debugging + * The original error will be accessible via the 'cause' property for debugging purposes. */ constructor( message: string, @@ -20,7 +30,9 @@ export class XmApiError extends Error { }, public override readonly cause?: unknown, ) { - super(message); + // If response is provided, extract a better message from it + const finalMessage = response ? XmApiError.extractErrorMessage(response) : message; + super(finalMessage); this.name = 'XmApiError'; // Ensure proper prototype chain for instanceof checks @@ -31,4 +43,50 @@ export class XmApiError extends Error { Error.captureStackTrace(this, this.constructor); } } + + /** + * Extracts a meaningful error message from the HTTP response. + * Prioritizes xMatters API's typical 'reason' and 'message' properties. + */ + private static extractErrorMessage(response: { + body: unknown; + status: number; + }): string { + // Default fallback message + const defaultMessage = `Request failed with status ${response.status}`; + + // If no response body, use default + if (!response.body) { + return defaultMessage; + } + + // If response body is a string, use it directly if it's not empty + if (typeof response.body === 'string') { + const trimmed = response.body.trim(); + return trimmed || defaultMessage; + } + + // If response body is not an object, use default + if (typeof response.body !== 'object') { + return defaultMessage; + } + + const body = response.body as Record; + + // xMatters API typically uses 'reason' for error type and 'message' for details + const reason = typeof body.reason === 'string' ? body.reason.trim() : ''; + const message = typeof body.message === 'string' ? body.message.trim() : ''; + + // If we have both reason and message, combine them + if (reason && message) { + return `${reason}: ${message}`; + } + + // If we only have one, use it + if (reason) return reason; + if (message) return message; + + // Fall back to default message + return defaultMessage; + } } diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index a542032..19a954d 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -227,24 +227,7 @@ export class RequestHandler { retryAttempt: currentAttempt + 1, }); } - - // Try to extract a descriptive message from the response - let message = `Request failed with status ${response.status}`; - if (response.body && typeof response.body === 'object' && 'message' in response.body) { - message = String(response.body.message); - } else if (typeof response.body === 'string' && response.body.trim()) { - message = response.body.trim(); - } - - // Add error code if available - if (response.body && typeof response.body === 'object' && 'code' in response.body) { - message = `${response.body.code}: ${message}`; - } - - throw new XmApiError( - message, - response, - ); + throw new XmApiError('', response); } return response as HttpResponse; From 13be60db6304d563bb580e567f16431eb734f4b4 Mon Sep 17 00:00:00 2001 From: johan Date: Sun, 8 Jun 2025 15:36:23 -0700 Subject: [PATCH 022/101] Unit test the logs --- src/endpoints/groups/index.test.ts | 175 ++++++++++++++++++++++++++++- 1 file changed, 170 insertions(+), 5 deletions(-) diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index f12f9cb..a459d0a 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -39,6 +39,8 @@ function createEndpointTestSetup(options: { refreshToken?: string; clientId?: string; maxRetries?: number; + onTokenRefresh?: (accessToken: string, refreshToken: string) => Promise; + expiredToken?: boolean; } = {}) { const { hostname = 'https://example.xmatters.com', @@ -48,6 +50,8 @@ function createEndpointTestSetup(options: { refreshToken, clientId, maxRetries = 3, + onTokenRefresh, + expiredToken = false, } = options; // Create silent mock logger @@ -70,11 +74,14 @@ function createEndpointTestSetup(options: { // Create token state for OAuth options if needed let tokenState: TokenState | undefined; if (accessToken) { + const expiresAt = expiredToken + ? new Date(Date.now() - 1000).toISOString() // Already expired + : new Date(Date.now() + 5 * 60 * 1000).toISOString(); // 5 minutes from now tokenState = { accessToken, refreshToken: refreshToken || '', clientId, - expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), // 5 minutes from now + expiresAt, scopes: [], }; } @@ -84,7 +91,7 @@ function createEndpointTestSetup(options: { mockLogger, mockOptions, maxRetries, - undefined, // onTokenRefresh callback + onTokenRefresh, tokenState, ); const endpoint = new GroupsEndpoint(requestHandler); @@ -474,7 +481,7 @@ Deno.test('GroupsEndpoint', async (t) => { ? Promise.resolve(rateLimitResponse) : Promise.resolve(successResponse); }); - const loggerStub = stub(mockLogger, 'debug', () => {}); + const debugStub = stub(mockLogger, 'debug', () => {}); try { // Start the request const requestPromise = endpoint.getGroups(); @@ -492,18 +499,176 @@ Deno.test('GroupsEndpoint', async (t) => { const retryRequest: HttpRequest = sendStub.calls[1].args[0]; expect(retryRequest.method).toBe('GET'); expect(retryRequest.path).toBe('/groups'); - expect(loggerStub.calls.length).toBe(1); - expect(loggerStub.calls[0].args[0]).toBe( + expect(debugStub.calls.length).toBe(1); + expect(debugStub.calls[0].args[0]).toBe( 'Request failed with status 429, retrying in 2000ms (attempt 1/3)', ); } finally { sendStub.restore(); + debugStub.restore(); } } finally { fakeTime.restore(); } }); + await t.step('retries on server error with debug logging', async () => { + const fakeTime = new FakeTime(); + try { + const { mockHttpClient, mockLogger, endpoint } = createEndpointTestSetup(); + const serverErrorResponse = { + status: 503, + headers: { 'content-type': 'application/json' }, + body: { message: 'Service unavailable' }, + }; + const successResponse = { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { count: 1, total: 1, data: [mockGroup] }, + }; + let callCount = 0; + const sendStub = stub(mockHttpClient, 'send', () => { + callCount++; + return callCount === 1 + ? Promise.resolve(serverErrorResponse) + : Promise.resolve(successResponse); + }); + const debugStub = stub(mockLogger, 'debug', () => {}); + try { + // Start the request + const requestPromise = endpoint.getGroups(); + // Allow the first request to complete and set up the timer + await fakeTime.nextAsync(); + // Now advance time to trigger the retry + await fakeTime.nextAsync(); + const response = await requestPromise; + expect(response.body).toEqual(successResponse.body); + expect(sendStub.calls.length).toBe(2); + // Verify debug logger was called with correct retry message + expect(debugStub.calls.length).toBe(1); + expect(debugStub.calls[0].args[0]).toBe( + 'Request failed with status 503, retrying in 1000ms (attempt 1/3)', + ); + } finally { + sendStub.restore(); + debugStub.restore(); + } + } finally { + fakeTime.restore(); + } + }); + + await t.step('logs warning when onTokenRefresh callback throws error', async () => { + // Create a RequestHandler with an onTokenRefresh callback that throws + const throwingCallback = () => { + throw new Error('Callback error'); + }; + const { mockHttpClient, mockLogger, endpoint } = createEndpointTestSetup({ + accessToken: 'expired-token', + refreshToken: 'valid-refresh-token', + clientId: 'test-client-id', + onTokenRefresh: throwingCallback, + expiredToken: true, + }); + const unauthorizedResponse = { + status: 401, + headers: { 'content-type': 'application/json' }, + body: { message: 'Token expired' }, + }; + + const tokenRefreshResponse = { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_in: 3600, + }, + }; + + const successResponse = { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { count: 1, total: 1, data: [mockGroup] }, + }; + + let callCount = 0; + const sendStub = stub(mockHttpClient, 'send', (request: HttpRequest) => { + callCount++; + // Check if this is a token refresh request + if (request.path === '/oauth2/token' || request.url?.includes('/oauth2/token')) { + return Promise.resolve(tokenRefreshResponse); + } + // Otherwise it's the main API request + if (callCount === 1) return Promise.resolve(unauthorizedResponse); + return Promise.resolve(successResponse); + }); + const warnStub = stub(mockLogger, 'warn', () => {}); + try { + const response = await endpoint.getGroups(); + expect(response.status).toBe(200); + expect(sendStub.calls.length).toBe(2); // token refresh, main request + expect(warnStub.calls.length).toBe(1); + expect(warnStub.calls[0].args[0]).toBe( + 'Error in onTokenRefresh callback, but continuing with refreshed token', + ); + expect(warnStub.calls[0].args[1]).toBeInstanceOf(Error); + expect((warnStub.calls[0].args[1] as Error).message).toBe('Callback error'); + } finally { + sendStub.restore(); + warnStub.restore(); + } + }); + + await t.step('logs error when token refresh fails', async () => { + const { mockHttpClient, mockLogger, endpoint } = createEndpointTestSetup({ + accessToken: 'expired-token', + refreshToken: 'invalid-refresh-token', + clientId: 'test-client-id', + expiredToken: true, + }); + + const unauthorizedResponse = { + status: 401, + headers: { 'content-type': 'application/json' }, + body: { message: 'Token expired' }, + }; + + const tokenRefreshErrorResponse = { + status: 400, + headers: { 'content-type': 'application/json' }, + body: { error: 'invalid_grant', error_description: 'Invalid refresh token' }, + }; + + let callCount = 0; + const sendStub = stub(mockHttpClient, 'send', (request: HttpRequest) => { + callCount++; + // Check if this is a token refresh request + if (request.path === '/oauth2/token' || request.url?.includes('/oauth2/token')) { + return Promise.resolve(tokenRefreshErrorResponse); + } + // Otherwise it's the main API request + return Promise.resolve(unauthorizedResponse); + }); + const errorStub = stub(mockLogger, 'error', () => {}); + try { + let thrownError: unknown; + try { + await endpoint.getGroups(); + } catch (error) { + thrownError = error; + } + expect(thrownError).toBeInstanceOf(XmApiError); + expect(sendStub.calls.length).toBe(1); // failed token refresh only + expect(errorStub.calls.length).toBe(1); + expect(errorStub.calls[0].args[0]).toBe('Failed to refresh token:'); + expect(errorStub.calls[0].args[1]).toBeInstanceOf(XmApiError); + } finally { + sendStub.restore(); + errorStub.restore(); + } + }); + await t.step('handles errors without response body', async () => { const { mockHttpClient, endpoint } = createEndpointTestSetup(); const errorResponse = { From 6f0c22707a115c5bbb6a3e5554f108584fa32855 Mon Sep 17 00:00:00 2001 From: johan Date: Sun, 8 Jun 2025 20:15:50 -0700 Subject: [PATCH 023/101] Implement OAuth endpoint Refactor XmApi class FTW --- src/core/errors.ts | 6 +- src/core/request-handler.test.ts | 46 ++-- src/core/request-handler.ts | 144 +++++++----- src/core/types/internal/config.ts | 23 +- src/endpoints/groups/index.test.ts | 47 ++-- src/endpoints/oauth/index.test.ts | 341 +++++++++++++++++++++++++++++ src/endpoints/oauth/index.ts | 142 ++++++++++++ src/endpoints/oauth/types.ts | 38 ++++ src/index.ts | 40 +--- 9 files changed, 681 insertions(+), 146 deletions(-) create mode 100644 src/endpoints/oauth/index.test.ts create mode 100644 src/endpoints/oauth/index.ts create mode 100644 src/endpoints/oauth/types.ts diff --git a/src/core/errors.ts b/src/core/errors.ts index b3b367f..b30bb79 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -30,8 +30,10 @@ export class XmApiError extends Error { }, public override readonly cause?: unknown, ) { - // If response is provided, extract a better message from it - const finalMessage = response ? XmApiError.extractErrorMessage(response) : message; + // Use custom message if provided and meaningful, otherwise extract from response + const finalMessage = (message && message.trim()) + ? message + : (response ? XmApiError.extractErrorMessage(response) : message); super(finalMessage); this.name = 'XmApiError'; diff --git a/src/core/request-handler.test.ts b/src/core/request-handler.test.ts index 30d12dd..9bb9612 100644 --- a/src/core/request-handler.test.ts +++ b/src/core/request-handler.test.ts @@ -15,7 +15,6 @@ import { FakeTime } from 'https://deno.land/std@0.224.0/testing/time.ts'; import { RequestHandler } from './request-handler.ts'; import type { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; import type { Logger, XmApiOptions } from './types/internal/config.ts'; -import type { TokenState } from './types/internal/oauth.ts'; import { XmApiError } from './errors.ts'; /** @@ -76,32 +75,21 @@ function createRequestHandlerTestSetup(options: { }; // Create auth options based on provided parameters - const mockOptions: XmApiOptions = accessToken - ? { hostname, accessToken, refreshToken, clientId } - : { hostname, username, password }; - const mockHttpClient = new MockHttpClient(responses); - // Create token state for OAuth options if needed - let tokenState: TokenState | undefined; - if (accessToken) { - tokenState = { + const mockOptions: XmApiOptions = accessToken + ? { + hostname, accessToken, - refreshToken: refreshToken || '', + refreshToken, clientId, - expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), // 5 minutes from now - scopes: [], - }; - } + maxRetries, + httpClient: mockHttpClient, + logger: mockLogger, + } + : { hostname, username, password, maxRetries, httpClient: mockHttpClient, logger: mockLogger }; - const requestHandler = new RequestHandler( - mockHttpClient, - mockLogger, - mockOptions, - maxRetries, - undefined, // onTokenRefresh callback - tokenState, - ); + const requestHandler = new RequestHandler(mockOptions); return { mockHttpClient, requestHandler, mockLogger }; } @@ -277,12 +265,14 @@ Deno.test('RequestHandler', async (t) => { send: () => Promise.reject(new Error('Network error')), }; - const networkRequestHandler = new RequestHandler( - mockHttpClient, - { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, - { hostname: 'https://example.xmatters.com', username: 'test', password: 'test' }, - 3, - ); + const networkRequestHandler = new RequestHandler({ + hostname: 'https://example.xmatters.com', + username: 'test', + password: 'test', + maxRetries: 3, + httpClient: mockHttpClient, + logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, + }); try { let thrownError: unknown; diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index 19a954d..5f072d8 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -1,47 +1,101 @@ import { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; import { + BasicAuthCredentials, isBasicAuthOptions, isOAuthOptions, Logger, + TokenRefreshCallback, XmApiOptions, } from './types/internal/config.ts'; import { DeleteOptions, GetOptions, RequestWithBodyOptions } from './types/internal/methods.ts'; import { XmApiError } from './errors.ts'; -import { TokenState } from './types/internal/oauth.ts'; +import { OAuth2TokenResponse, TokenState } from './types/internal/oauth.ts'; import { RequestBuilder } from './request-builder.ts'; +import { DefaultHttpClient, defaultLogger } from './defaults/index.ts'; export class RequestHandler { + /** HTTP client for making requests */ + private readonly client: HttpClient; + /** Logger for debug output */ + private readonly logger: Logger; /** Current token state if using OAuth */ private tokenState?: TokenState; - /** Request builder for creating HTTP requests */ + /** Request builder for creating HTTP requests before sending with the client */ private readonly requestBuilder: RequestBuilder; + /** Optional callback for token refresh events */ + private readonly onTokenRefresh?: TokenRefreshCallback; + /** Maximum number of retry attempts for failed requests */ + private readonly maxRetries: number; constructor( - private readonly client: HttpClient, - private readonly logger: Logger, private readonly options: XmApiOptions, - private readonly maxRetries: number = 3, - private readonly onTokenRefresh?: ( - accessToken: string, - refreshToken: string, - ) => void | Promise, - tokenState?: TokenState, ) { - // If we have token state, store it - if (tokenState) { - this.tokenState = tokenState; + // Set up internal properties + this.client = options.httpClient ?? new DefaultHttpClient(); + this.logger = options.logger ?? defaultLogger; + this.onTokenRefresh = options.onTokenRefresh; + this.maxRetries = options.maxRetries ?? 3; + // Create initial token state for OAuth if needed + if (isOAuthOptions(options)) { + this.tokenState = { + accessToken: options.accessToken, + refreshToken: options.refreshToken || '', + clientId: options.clientId, + // Set a default expiry 5 minutes from now - we'll get the real value on first refresh + expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), + scopes: [], + }; } - // Create request builder const headers: Record = { 'Content-Type': 'application/json', 'Accept': 'application/json', ...options.defaultHeaders, }; - this.requestBuilder = new RequestBuilder(options.hostname, headers); } + /** + * Handle newly acquired or refreshed OAuth tokens. + * + * This method processes token responses from any source and updates the internal state: + * - OAuth endpoint responses (password grant, authorization code grant) + * - Automatic token refresh during request retry + * + * The method will: + * 1. Update the internal token state with new token data + * 2. Calculate and set the token expiration time + * 3. Execute the onTokenRefresh callback if provided (with error handling) + * + * @param tokenResponse - The token response object from the xMatters API + * @param clientId - Optional client ID for OAuth2 operations (preserved from previous state if not provided) + */ + async handleNewTokens( + tokenResponse: OAuth2TokenResponse, + clientId?: string, + ): Promise { + // Update token state + this.tokenState = { + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + clientId: clientId ?? this.tokenState?.clientId, + expiresAt: new Date(Date.now() + (tokenResponse.expires_in * 1000)).toISOString(), + scopes: tokenResponse.scope?.split(' ') ?? [], + }; + // Execute callback if provided + if (this.onTokenRefresh) { + try { + await this.onTokenRefresh(tokenResponse.access_token, tokenResponse.refresh_token); + } catch (error) { + // Use proper logger instead of console + this.logger.warn( + 'Error in onTokenRefresh callback, but continuing with refreshed token', + error, + ); + } + } + } + private isTokenExpired(): boolean { if (!this.tokenState) return false; const expiresAt = new Date(this.tokenState.expiresAt); @@ -77,52 +131,11 @@ export class RequestHandler { const response = await this.client.send(refreshRequest); - if (response.status !== 200 || !response.body) { + if (response.status < 200 || response.status >= 300) { throw new XmApiError('Failed to refresh token', response); } - if (typeof response.body !== 'object' || !response.body) { - throw new XmApiError( - 'Invalid token response format', - response, - ); - } - - const body = response.body as { - access_token?: string; - refresh_token?: string; - expires_in?: number; - scope?: string; - }; - - if (!body.access_token || !body.refresh_token) { - throw new XmApiError( - 'Token response missing required fields', - response, - ); - } - - this.tokenState = { - ...this.tokenState, // Preserve clientId and other fields - accessToken: body.access_token, - refreshToken: body.refresh_token, - scopes: body.scope?.split(' ') ?? [], - expiresAt: new Date(Date.now() + ((body.expires_in ?? 3600) * 1000)).toISOString(), - }; - - if (this.onTokenRefresh) { - try { - await this.onTokenRefresh( - this.tokenState.accessToken, - this.tokenState.refreshToken, - ); - } catch (error) { - this.logger.warn( - 'Error in onTokenRefresh callback, but continuing with refreshed token', - error, - ); - } - } + await this.handleNewTokens(response.body as OAuth2TokenResponse, this.tokenState?.clientId); } catch (error) { this.logger.error('Failed to refresh token:', error); throw error; @@ -258,4 +271,19 @@ export class RequestHandler { delete(options: DeleteOptions): Promise> { return this.send({ ...options, method: 'DELETE' }); } + + /** + * Get basic auth credentials from constructor options if available. + * This allows endpoints to access these credentials for OAuth token acquisition. + */ + getBasicAuthCredentials(): BasicAuthCredentials | undefined { + if (isBasicAuthOptions(this.options)) { + return { + username: this.options.username, + password: this.options.password, + clientId: this.options.clientId, + }; + } + return undefined; + } } diff --git a/src/core/types/internal/config.ts b/src/core/types/internal/config.ts index c53015a..58cca75 100644 --- a/src/core/types/internal/config.ts +++ b/src/core/types/internal/config.ts @@ -16,6 +16,15 @@ export interface Logger { error: (message: string, ...args: unknown[]) => void; } +/** + * Callback function type for token refresh events. + * Called when OAuth tokens are refreshed or initially acquired. + */ +export type TokenRefreshCallback = ( + accessToken: string, + refreshToken: string, +) => void | Promise; + /** * Base configuration options shared by all authentication methods. */ @@ -33,8 +42,20 @@ export interface XmApiBaseOptions { export interface XmApiBasicAuthOptions extends XmApiBaseOptions { username: string; password: string; + clientId?: string; // Optional for OAuth token acquisition + onTokenRefresh?: TokenRefreshCallback; // Optional callback for when OAuth tokens are acquired/refreshed } +/** + * Basic authentication credentials structure. + * Used when extracting credentials for OAuth token acquisition. + * This is a subset of XmApiBasicAuthOptions containing only the auth fields. + */ +export type BasicAuthCredentials = Pick< + XmApiBasicAuthOptions, + 'username' | 'password' | 'clientId' +>; + /** * Configuration options for OAuth authentication with existing tokens. */ @@ -42,7 +63,7 @@ export interface XmApiOAuthOptions extends XmApiBaseOptions { accessToken: string; refreshToken?: string; clientId?: string; - onTokenRefresh?: (accessToken: string, refreshToken: string) => void | Promise; + onTokenRefresh?: TokenRefreshCallback; } /** diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index a459d0a..f4bcb6a 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -27,7 +27,6 @@ import { RequestHandler } from '../../core/request-handler.ts'; import type { HttpClient, HttpRequest } from '../../core/types/internal/http.ts'; import type { Logger, XmApiOptions } from '../../core/types/internal/config.ts'; import type { Group } from './types.ts'; -import type { TokenState } from '../../core/types/internal/oauth.ts'; import { XmApiError } from '../../core/errors.ts'; // Test helper to create mock setup @@ -51,7 +50,6 @@ function createEndpointTestSetup(options: { clientId, maxRetries = 3, onTokenRefresh, - expiredToken = false, } = options; // Create silent mock logger @@ -63,37 +61,32 @@ function createEndpointTestSetup(options: { }; // Create auth options based on provided parameters - const mockOptions: XmApiOptions = accessToken - ? { hostname, accessToken, refreshToken, clientId } - : { hostname, username, password }; - const mockHttpClient: HttpClient = { send: () => Promise.resolve({ status: 200, headers: {}, body: {} }), }; - // Create token state for OAuth options if needed - let tokenState: TokenState | undefined; - if (accessToken) { - const expiresAt = expiredToken - ? new Date(Date.now() - 1000).toISOString() // Already expired - : new Date(Date.now() + 5 * 60 * 1000).toISOString(); // 5 minutes from now - tokenState = { + const mockOptions: XmApiOptions = accessToken + ? { + hostname, accessToken, - refreshToken: refreshToken || '', + refreshToken, clientId, - expiresAt, - scopes: [], + onTokenRefresh, + maxRetries, + httpClient: mockHttpClient, + logger: mockLogger, + } + : { + hostname, + username, + password, + onTokenRefresh, + maxRetries, + httpClient: mockHttpClient, + logger: mockLogger, }; - } - const requestHandler = new RequestHandler( - mockHttpClient, - mockLogger, - mockOptions, - maxRetries, - onTokenRefresh, - tokenState, - ); + const requestHandler = new RequestHandler(mockOptions); const endpoint = new GroupsEndpoint(requestHandler); return { mockHttpClient, endpoint, mockLogger }; @@ -607,7 +600,7 @@ Deno.test('GroupsEndpoint', async (t) => { try { const response = await endpoint.getGroups(); expect(response.status).toBe(200); - expect(sendStub.calls.length).toBe(2); // token refresh, main request + expect(sendStub.calls.length).toBe(3); // initial request (401), token refresh, retry request expect(warnStub.calls.length).toBe(1); expect(warnStub.calls[0].args[0]).toBe( 'Error in onTokenRefresh callback, but continuing with refreshed token', @@ -659,7 +652,7 @@ Deno.test('GroupsEndpoint', async (t) => { thrownError = error; } expect(thrownError).toBeInstanceOf(XmApiError); - expect(sendStub.calls.length).toBe(1); // failed token refresh only + expect(sendStub.calls.length).toBe(2); // initial request (401), failed token refresh expect(errorStub.calls.length).toBe(1); expect(errorStub.calls[0].args[0]).toBe('Failed to refresh token:'); expect(errorStub.calls[0].args[1]).toBeInstanceOf(XmApiError); diff --git a/src/endpoints/oauth/index.test.ts b/src/endpoints/oauth/index.test.ts new file mode 100644 index 0000000..782c5be --- /dev/null +++ b/src/endpoints/oauth/index.test.ts @@ -0,0 +1,341 @@ +/** + * Unit tests for OAuth endpoint - Password Grant Flow + */ + +import { expect } from 'https://deno.land/std@0.224.0/expect/mod.ts'; + +import { OAuthEndpoint } from './index.ts'; +import { RequestHandler } from '../../core/request-handler.ts'; +import type { HttpClient, HttpRequest, HttpResponse } from '../../core/types/internal/http.ts'; +import type { + Logger, + TokenRefreshCallback, + XmApiOptions, +} from '../../core/types/internal/config.ts'; +import type { TokenResponse } from './types.ts'; + +/** + * Mock HTTP client for testing OAuth endpoint + */ +class MockHttpClient implements HttpClient { + private responses: HttpResponse[] = []; + private callIndex = 0; + public requests: HttpRequest[] = []; + + constructor(responses: HttpResponse[]) { + this.responses = responses; + } + + send(request: HttpRequest): Promise> { + this.requests.push({ ...request }); + + if (this.callIndex >= this.responses.length) { + throw new Error('MockHttpClient: No more responses configured'); + } + + const response = this.responses[this.callIndex]; + this.callIndex++; + return Promise.resolve(response as HttpResponse); + } + + reset() { + this.requests = []; + this.callIndex = 0; + } + + setResponses(responses: HttpResponse[]) { + this.responses = responses; + this.callIndex = 0; + } +} + +/** + * Helper to create a RequestHandler with mock dependencies for testing + */ +function createTestRequestHandler(options: { + hostname?: string; + username?: string; + password?: string; + clientId?: string; + accessToken?: string; + refreshToken?: string; + onTokenRefresh?: TokenRefreshCallback; + responses?: HttpResponse[]; +} = {}) { + const { + hostname = 'https://test.xmatters.com', + username, + password, + clientId, + accessToken, + refreshToken, + onTokenRefresh, + responses = [], + } = options; + + // Create silent mock logger + const mockLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }; + + const mockHttpClient = new MockHttpClient(responses); + + // Create auth options based on provided parameters + let mockOptions: XmApiOptions; + if (accessToken) { + mockOptions = { + hostname, + accessToken, + refreshToken, + clientId, + onTokenRefresh, + maxRetries: 3, + httpClient: mockHttpClient, + logger: mockLogger, + }; + } else { + // Create basic auth options even with missing fields so OAuth endpoint can validate them specifically + // Use a partial basic auth config to test missing field validation + mockOptions = { + hostname, + username: username!, + password: password!, + clientId, + onTokenRefresh, + maxRetries: 3, + httpClient: mockHttpClient, + logger: mockLogger, + } as XmApiOptions; + } + + const requestHandler = new RequestHandler(mockOptions); + + return { requestHandler, mockHttpClient, mockLogger }; +} + +/** + * Mock successful token response + */ +const mockTokenResponse: HttpResponse = { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_in: 900, + token_type: 'bearer', + scope: 'read write', + }, +}; + +Deno.test('OAuthEndpoint - getTokensByCredentials() - successful token acquisition', async () => { + const { requestHandler, mockHttpClient } = createTestRequestHandler({ + username: 'test-user', + password: 'test-password', + clientId: 'test-client-id', + responses: [mockTokenResponse], + }); + + const oauthEndpoint = new OAuthEndpoint(requestHandler); + const response = await oauthEndpoint.getTokensByCredentials(); + + // Verify the request was made correctly + expect(mockHttpClient.requests).toHaveLength(1); + const request = mockHttpClient.requests[0]; + + expect(request.method).toBe('POST'); + expect(request.path).toBe('/oauth2/token'); + expect(request.headers?.['Content-Type']).toBe('application/x-www-form-urlencoded'); + expect(request.headers?.['Accept']).toBe('application/json'); + // Note: skipAuth is handled by RequestHandler.send() and not passed to the HTTP client + expect(request.headers?.['Authorization']).toBeUndefined(); // Auth header should be skipped + + // Verify request body contains correct form data + const expectedBody = new URLSearchParams({ + grant_type: 'password', + client_id: 'test-client-id', + username: 'test-user', + password: 'test-password', + }).toString(); + expect(request.body).toBe(expectedBody); + + // Verify response structure + expect(response.status).toBe(200); + expect(response.body.access_token).toBe('test-access-token'); + expect(response.body.refresh_token).toBe('test-refresh-token'); + expect(response.body.expires_in).toBe(900); + expect(response.body.token_type).toBe('bearer'); + expect(response.body.scope).toBe('read write'); +}); + +Deno.test('OAuthEndpoint - getTokensByCredentials() - throws error when no constructor credentials', async () => { + const { requestHandler, mockHttpClient: _ } = createTestRequestHandler({ + accessToken: 'existing-token', // OAuth mode, no basic auth + responses: [], + }); + + const oauthEndpoint = new OAuthEndpoint(requestHandler); + + await expect(oauthEndpoint.getTokensByCredentials()).rejects.toThrow( + 'XmApi must be initialized with basic auth credentials (username, password, clientId) to acquire OAuth tokens.', + ); + + // Verify no HTTP request was made + expect(_.requests).toHaveLength(0); +}); + +Deno.test('OAuthEndpoint - getTokensByCredentials() - throws error when clientId is missing', async () => { + const { requestHandler, mockHttpClient: _ } = createTestRequestHandler({ + username: 'test-user', + password: 'test-password', + // Missing clientId + responses: [], + }); + + const oauthEndpoint = new OAuthEndpoint(requestHandler); + + await expect(oauthEndpoint.getTokensByCredentials()).rejects.toThrow( + 'clientId is required for OAuth token acquisition. Provide it in the XmApi constructor.', + ); + + expect(_.requests).toHaveLength(0); +}); + +Deno.test('OAuthEndpoint - getTokensByCredentials() - throws error when username is missing', async () => { + const { requestHandler, mockHttpClient: _ } = createTestRequestHandler({ + password: 'test-password', + clientId: 'test-client-id', + // Missing username + responses: [], + }); + + const oauthEndpoint = new OAuthEndpoint(requestHandler); + + await expect(oauthEndpoint.getTokensByCredentials()).rejects.toThrow( + 'username is required for OAuth token acquisition. Provide it in the XmApi constructor.', + ); + + expect(_.requests).toHaveLength(0); +}); + +Deno.test('OAuthEndpoint - getTokensByCredentials() - throws error when password is missing', async () => { + const { requestHandler, mockHttpClient: _ } = createTestRequestHandler({ + username: 'test-user', + clientId: 'test-client-id', + // Missing password + responses: [], + }); + + const oauthEndpoint = new OAuthEndpoint(requestHandler); + + await expect(oauthEndpoint.getTokensByCredentials()).rejects.toThrow( + 'password is required for OAuth token acquisition. Provide it in the XmApi constructor.', + ); + + expect(_.requests).toHaveLength(0); +}); + +Deno.test('OAuthEndpoint - getTokensByCredentials() - throws error when API returns non-200 status', async () => { + const errorResponse: HttpResponse = { + status: 401, + headers: { 'content-type': 'application/json' }, + body: { error: 'invalid_client', error_description: 'Client authentication failed' }, + }; + + const { requestHandler, mockHttpClient } = createTestRequestHandler({ + username: 'test-user', + password: 'test-password', + clientId: 'test-client-id', + responses: [errorResponse], + }); + + const oauthEndpoint = new OAuthEndpoint(requestHandler); + + await expect(oauthEndpoint.getTokensByCredentials()).rejects.toThrow( + 'Request failed with status 401', + ); + + expect(mockHttpClient.requests).toHaveLength(1); +}); + +Deno.test('OAuthEndpoint - getTokensByCredentials() - calls token refresh callback when provided', async () => { + let callbackCalled = false; + let receivedAccessToken = ''; + let receivedRefreshToken = ''; + + const { requestHandler, mockHttpClient: _ } = createTestRequestHandler({ + username: 'test-user', + password: 'test-password', + clientId: 'test-client-id', + onTokenRefresh: (accessToken, refreshToken) => { + callbackCalled = true; + receivedAccessToken = accessToken; + receivedRefreshToken = refreshToken; + }, + responses: [mockTokenResponse], + }); + + const oauthEndpoint = new OAuthEndpoint(requestHandler); + await oauthEndpoint.getTokensByCredentials(); + + // Verify callback was called with correct tokens + expect(callbackCalled).toBe(true); + expect(receivedAccessToken).toBe('test-access-token'); + expect(receivedRefreshToken).toBe('test-refresh-token'); +}); + +Deno.test('OAuthEndpoint - getTokensByCredentials() - does not fail if token refresh callback throws error', async () => { + const { requestHandler, mockHttpClient: _ } = createTestRequestHandler({ + username: 'test-user', + password: 'test-password', + clientId: 'test-client-id', + onTokenRefresh: () => { + throw new Error('Callback error'); + }, + responses: [mockTokenResponse], + }); + + const oauthEndpoint = new OAuthEndpoint(requestHandler); + + // Should not throw error even though callback fails + const response = await oauthEndpoint.getTokensByCredentials(); + expect(response.body.access_token).toBe('test-access-token'); +}); + +Deno.test('OAuthEndpoint - getTokensByCredentials() - returns raw API response without field transformation', async () => { + // Response with actual API field names (snake_case) + const apiResponse: HttpResponse = { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { + access_token: 'raw-access-token', + refresh_token: 'raw-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + scope: 'api read write', + }, + }; + + const { requestHandler, mockHttpClient: _ } = createTestRequestHandler({ + username: 'test-user', + password: 'test-password', + clientId: 'test-client-id', + responses: [apiResponse], + }); + + const oauthEndpoint = new OAuthEndpoint(requestHandler); + const response = await oauthEndpoint.getTokensByCredentials(); + + // Verify that field names are preserved exactly as returned by API + expect(response.body.access_token).toBe('raw-access-token'); + expect(response.body.refresh_token).toBe('raw-refresh-token'); + expect(response.body.expires_in).toBe(3600); + expect(response.body.token_type).toBe('Bearer'); + expect(response.body.scope).toBe('api read write'); + + // Verify that the response is the exact same object returned by HTTP client + expect(response).toBe(apiResponse); +}); diff --git a/src/endpoints/oauth/index.ts b/src/endpoints/oauth/index.ts new file mode 100644 index 0000000..680f94d --- /dev/null +++ b/src/endpoints/oauth/index.ts @@ -0,0 +1,142 @@ +import { RequestHandler } from '../../core/request-handler.ts'; +import { XmApiError } from '../../core/errors.ts'; +import { TokenByAuthCodeParams, TokenResponse } from './types.ts'; +import type { HttpResponse } from '../../core/types/internal/http.ts'; + +export class OAuthEndpoint { + constructor( + private readonly http: RequestHandler, + ) {} + + /** + * Obtain OAuth tokens using username and password from constructor, then automatically switch to OAuth mode. + * After calling this method, all subsequent API calls will use the acquired OAuth tokens. + * This is the "password" grant type in OAuth2 terminology. + * + * The username, password, and clientId must be provided in the XmApi constructor. + * + * @returns Promise resolving to HTTP response containing token information + * + * @example + * ```typescript + * const xm = new XmApi({ + * hostname: 'https://example.xmatters.com', + * username: 'your-username', + * password: 'your-password', + * clientId: 'your-client-id' + * }); + * + * const { body: tokens } = await xm.oauth.getTokensByCredentials(); + * ``` + */ + async getTokensByCredentials(): Promise> { + // Get constructor credentials from RequestHandler + const constructorCredentials = this.http.getBasicAuthCredentials(); + if (!constructorCredentials) { + throw new XmApiError( + 'XmApi must be initialized with basic auth credentials (username, password, clientId) to acquire OAuth tokens.', + ); + } + const { clientId, username, password } = constructorCredentials; + // Validate that we have all required credentials + if (!clientId) { + throw new XmApiError( + 'clientId is required for OAuth token acquisition. Provide it in the XmApi constructor.', + ); + } + if (!username) { + throw new XmApiError( + 'username is required for OAuth token acquisition. Provide it in the XmApi constructor.', + ); + } + if (!password) { + throw new XmApiError( + 'password is required for OAuth token acquisition. Provide it in the XmApi constructor.', + ); + } + const requestBody = new URLSearchParams({ + grant_type: 'password', + client_id: clientId, + username, + password, + }); + const response = await this.http.send({ + method: 'POST', + path: '/oauth2/token', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }, + body: requestBody.toString(), + skipAuth: true, // Don't add auth headers for token acquisition + }); + const tokenData = response.body; + + // Handle the newly acquired tokens + await this.http.handleNewTokens(tokenData, clientId); + + return response; + } + + /** + * Obtain OAuth tokens using authorization code, then automatically switch to OAuth mode. + * After calling this method, all subsequent API calls will use the acquired OAuth tokens. + * This is the "authorization_code" grant type in OAuth2 terminology. + * + * @param params - The authorization code, client ID, and related parameters + * @returns Promise resolving to HTTP response containing token information + * + * @example + * ```typescript + * const xm = new XmApi({ + * hostname: 'https://example.xmatters.com', + * }); + * + * const { body: tokens } = await xm.oauth.getTokensByAuthCode({ + * clientId: 'your-client-id', + * code: 'authorization-code-from-callback', + * redirectUri: 'https://your-app.com/callback' + * }); + * + * // Now all subsequent API calls use OAuth + * const groups = await xm.groups.getGroups(); + * ``` + */ + async getTokensByAuthCode(params: TokenByAuthCodeParams): Promise> { + // untested WIP + const { clientId, code, redirectUri, codeVerifier } = params; + + const requestParams = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + code, + }); + + if (redirectUri) { + requestParams.append('redirect_uri', redirectUri); + } + + if (codeVerifier) { + requestParams.append('code_verifier', codeVerifier); + } + + const response = await this.http.send({ + method: 'POST', + path: '/oauth2/token', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }, + body: requestParams.toString(), + skipAuth: true, // Don't add auth headers for token acquisition + }); + + const tokenData = response.body; + + // Handle the newly acquired tokens + await this.http.handleNewTokens(tokenData, clientId); + + // Return the full HTTP response with raw token data (no transformation) + return response; + } +} diff --git a/src/endpoints/oauth/types.ts b/src/endpoints/oauth/types.ts new file mode 100644 index 0000000..5278383 --- /dev/null +++ b/src/endpoints/oauth/types.ts @@ -0,0 +1,38 @@ +/** + * Types specific to the OAuth endpoint. + * These types define the request and response structures for OAuth token operations. + */ + +/** + * Request parameters for obtaining OAuth tokens using authorization code grant. + * + * All parameters required for the authorization code flow, including those + * generated during the OAuth authorization process. + */ +export interface TokenByAuthCodeParams { + /** The client ID for the OAuth application */ + clientId: string; + /** Authorization code received from the authorization server */ + code: string; + /** Redirect URI that was used in the authorization request */ + redirectUri?: string; + /** Code verifier for PKCE (Proof Key for Code Exchange) */ + codeVerifier?: string; +} + +/** + * The response returned when successfully obtaining OAuth tokens. + * This matches the exact format returned by the xMatters API. + */ +export interface TokenResponse { + /** The access token to use for authenticated requests */ + access_token: string; + /** Token to use to get a new access token when it expires */ + refresh_token: string; + /** How many seconds until the access token expires */ + expires_in: number; + /** The type of token, typically 'Bearer' */ + token_type: string; + /** The scopes granted to the token (space-separated string) */ + scope?: string; +} diff --git a/src/index.ts b/src/index.ts index 8e7a809..e4e605f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { RequestHandler } from './core/request-handler.ts'; -import { DefaultHttpClient, defaultLogger } from './core/defaults/index.ts'; -import { isOAuthOptions, XmApiOptions } from './core/types/internal/config.ts'; +import { XmApiOptions } from './core/types/internal/config.ts'; import { GroupsEndpoint } from './endpoints/groups/index.ts'; +import { OAuthEndpoint } from './endpoints/oauth/index.ts'; /** * Main entry point for the xMatters API client. @@ -45,41 +45,19 @@ export class XmApi { /** Access groups-related endpoints */ public readonly groups: GroupsEndpoint; - constructor(private readonly options: XmApiOptions) { - const { - httpClient = new DefaultHttpClient(), - logger = defaultLogger, - maxRetries = 3, - } = options; - - // Create initial token state for OAuth if needed - let initialTokenState; - if (isOAuthOptions(options)) { - initialTokenState = { - accessToken: options.accessToken, - refreshToken: options.refreshToken || '', - clientId: options.clientId, - // Set a default expiry 5 minutes from now - we'll get the real value on first refresh - expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), - scopes: [], - }; - } + /** Access OAuth-related endpoints for token acquisition */ + public readonly oauth: OAuthEndpoint; - this.http = new RequestHandler( - httpClient, - logger, - options, - maxRetries, - isOAuthOptions(options) ? options.onTokenRefresh : undefined, - initialTokenState, - ); + constructor(private readonly options: XmApiOptions) { + this.http = new RequestHandler(options); // Initialize endpoints this.groups = new GroupsEndpoint(this.http); + this.oauth = new OAuthEndpoint(this.http); } } -// Re-export types +// Re-export types and errors export * from './core/types/internal/config.ts'; export * from './core/types/internal/http.ts'; export * from './core/types/internal/oauth.ts'; @@ -87,3 +65,5 @@ export * from './core/types/endpoint/response.ts'; export * from './core/types/endpoint/composers.ts'; export * from './core/types/endpoint/params.ts'; export * from './endpoints/groups/types.ts'; +export * from './endpoints/oauth/types.ts'; +export { XmApiError } from './core/errors.ts'; From 95aaa4a96ab14b9dbe5213190cae33ec705dd5e2 Mon Sep 17 00:00:00 2001 From: johan Date: Sun, 8 Jun 2025 20:19:03 -0700 Subject: [PATCH 024/101] Minor types tweak --- src/core/types/internal/config.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/core/types/internal/config.ts b/src/core/types/internal/config.ts index 58cca75..9946e1e 100644 --- a/src/core/types/internal/config.ts +++ b/src/core/types/internal/config.ts @@ -34,6 +34,7 @@ export interface XmApiBaseOptions { logger?: Logger; defaultHeaders?: Record; maxRetries?: number; + onTokenRefresh?: TokenRefreshCallback; // Optional callback for when OAuth tokens are acquired/refreshed } /** @@ -43,7 +44,6 @@ export interface XmApiBasicAuthOptions extends XmApiBaseOptions { username: string; password: string; clientId?: string; // Optional for OAuth token acquisition - onTokenRefresh?: TokenRefreshCallback; // Optional callback for when OAuth tokens are acquired/refreshed } /** @@ -63,7 +63,6 @@ export interface XmApiOAuthOptions extends XmApiBaseOptions { accessToken: string; refreshToken?: string; clientId?: string; - onTokenRefresh?: TokenRefreshCallback; } /** From 7d104ad220d2c8fb3f766273bd551fb04ccd1357 Mon Sep 17 00:00:00 2001 From: johan Date: Sun, 8 Jun 2025 20:32:30 -0700 Subject: [PATCH 025/101] Fix lint error --- src/core/resource-client.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/resource-client.ts b/src/core/resource-client.ts index 400d92e..366f728 100644 --- a/src/core/resource-client.ts +++ b/src/core/resource-client.ts @@ -1,4 +1,3 @@ -import type { HttpRequest } from './types/internal/http.ts'; import type { DeleteOptions, GetOptions, From b2c5487265937ff02e3d527abbaecff9d2073a73 Mon Sep 17 00:00:00 2001 From: johan Date: Mon, 9 Jun 2025 00:13:27 -0700 Subject: [PATCH 026/101] Always throw XmApiError instances Fix OAuth configuration requirements --- src/core/request-builder.test.ts | 13 +- src/core/request-builder.ts | 7 +- src/core/request-handler.test.ts | 259 ++++++++++++++++++++++++++++- src/core/request-handler.ts | 22 +-- src/core/resource-client.test.ts | 212 +++++++++++++++++++++++ src/core/resource-client.ts | 3 +- src/core/types/internal/config.ts | 5 +- src/core/types/internal/oauth.ts | 6 +- src/endpoints/groups/index.test.ts | 15 +- src/endpoints/oauth/index.test.ts | 17 +- src/index.ts | 3 +- 11 files changed, 511 insertions(+), 51 deletions(-) create mode 100644 src/core/resource-client.test.ts diff --git a/src/core/request-builder.test.ts b/src/core/request-builder.test.ts index 77cfd8c..faa1826 100644 --- a/src/core/request-builder.test.ts +++ b/src/core/request-builder.test.ts @@ -1,5 +1,6 @@ import { expect } from 'https://deno.land/std@0.224.0/expect/mod.ts'; import { RequestBuilder, RequestBuildOptions } from './request-builder.ts'; +import { XmApiError } from './errors.ts'; // Test helper to create RequestBuilder with standard configuration function createRequestBuilderTestSetup(options: { @@ -181,8 +182,8 @@ Deno.test('RequestBuilder', async (t) => { } catch (error) { thrownError = error; } - expect(thrownError).toBeInstanceOf(Error); - const error = thrownError as Error; + expect(thrownError).toBeInstanceOf(XmApiError); + const error = thrownError as XmApiError; expect(error.message).toBe('Path must start with a forward slash, e.g. "/people"'); }); @@ -197,8 +198,8 @@ Deno.test('RequestBuilder', async (t) => { } catch (error) { thrownError = error; } - expect(thrownError).toBeInstanceOf(Error); - const error = thrownError as Error; + expect(thrownError).toBeInstanceOf(XmApiError); + const error = thrownError as XmApiError; expect(error.message).toBe( 'Cannot specify both fullUrl and path. Use fullUrl for external endpoints, path for xMatters API endpoints.', ); @@ -212,8 +213,8 @@ Deno.test('RequestBuilder', async (t) => { } catch (error) { thrownError = error; } - expect(thrownError).toBeInstanceOf(Error); - const error = thrownError as Error; + expect(thrownError).toBeInstanceOf(XmApiError); + const error = thrownError as XmApiError; expect(error.message).toBe('Either path or fullUrl must be provided'); }); diff --git a/src/core/request-builder.ts b/src/core/request-builder.ts index 6b7854d..6a58733 100644 --- a/src/core/request-builder.ts +++ b/src/core/request-builder.ts @@ -1,4 +1,5 @@ import { HttpRequest } from './types/internal/http.ts'; +import { XmApiError } from './errors.ts'; /** * Request options for building HTTP requests. @@ -33,7 +34,7 @@ export class RequestBuilder { let url: URL; if (options.fullUrl && options.path) { - throw new Error( + throw new XmApiError( 'Cannot specify both fullUrl and path. Use fullUrl for external endpoints, path for xMatters API endpoints.', ); } @@ -42,11 +43,11 @@ export class RequestBuilder { url = new URL(options.fullUrl); } else if (options.path) { if (!options.path.startsWith('/')) { - throw new Error('Path must start with a forward slash, e.g. "/people"'); + throw new XmApiError('Path must start with a forward slash, e.g. "/people"'); } url = new URL(`${this.apiVersionPath}${options.path}`, this.baseUrl); } else { - throw new Error('Either path or fullUrl must be provided'); + throw new XmApiError('Either path or fullUrl must be provided'); } // Add query parameters if present in the options diff --git a/src/core/request-handler.test.ts b/src/core/request-handler.test.ts index 9bb9612..ed6ab2c 100644 --- a/src/core/request-handler.test.ts +++ b/src/core/request-handler.test.ts @@ -12,6 +12,7 @@ import { expect } from 'https://deno.land/std@0.224.0/expect/mod.ts'; import { FakeTime } from 'https://deno.land/std@0.224.0/testing/time.ts'; +import { stub } from 'https://deno.land/std@0.224.0/testing/mock.ts'; import { RequestHandler } from './request-handler.ts'; import type { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; import type { Logger, XmApiOptions } from './types/internal/config.ts'; @@ -77,8 +78,10 @@ function createRequestHandlerTestSetup(options: { // Create auth options based on provided parameters const mockHttpClient = new MockHttpClient(responses); - const mockOptions: XmApiOptions = accessToken - ? { + let mockOptions: XmApiOptions; + if (accessToken && refreshToken && clientId) { + // OAuth configuration - all three are required + mockOptions = { hostname, accessToken, refreshToken, @@ -86,8 +89,18 @@ function createRequestHandlerTestSetup(options: { maxRetries, httpClient: mockHttpClient, logger: mockLogger, - } - : { hostname, username, password, maxRetries, httpClient: mockHttpClient, logger: mockLogger }; + }; + } else { + // Basic auth configuration + mockOptions = { + hostname, + username, + password, + maxRetries, + httpClient: mockHttpClient, + logger: mockLogger, + }; + } const requestHandler = new RequestHandler(mockOptions); @@ -316,7 +329,7 @@ Deno.test('RequestHandler', async (t) => { const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup({ accessToken: 'test-access-token', refreshToken: 'test-refresh-token', - clientId: 'client-id', + clientId: 'test-client-id', }); try { @@ -334,7 +347,7 @@ Deno.test('RequestHandler', async (t) => { const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup({ accessToken: 'old-token', refreshToken: 'refresh-token', - clientId: 'client-id', + clientId: 'test-client-id', responses: [mockUnauthorizedResponse, mockTokenRefreshResponse, mockSuccessResponse], }); @@ -353,7 +366,7 @@ Deno.test('RequestHandler', async (t) => { const params = new URLSearchParams(refreshRequest.body as string); expect(params.get('grant_type')).toBe('refresh_token'); expect(params.get('refresh_token')).toBe('refresh-token'); - expect(params.get('client_id')).toBe('client-id'); + expect(params.get('client_id')).toBe('test-client-id'); // Verify retried request uses new token const retriedRequest = mockHttpClient.requests[2]; @@ -376,4 +389,236 @@ Deno.test('RequestHandler', async (t) => { mockHttpClient.reset(); } }); + + await t.step('logs debug message when retrying requests', async () => { + const fakeTime = new FakeTime(); + try { + const { mockHttpClient, requestHandler, mockLogger } = createRequestHandlerTestSetup({ + responses: [mockRateLimitResponse, mockSuccessResponse], + }); + + // Stub the debug method to capture calls + const debugStub = stub(mockLogger, 'debug', () => {}); + + try { + const requestPromise = requestHandler.get({ path: '/test' }); + + // Allow the first request to complete and set up the timer + await fakeTime.nextAsync(); + // Now advance time to trigger the retry + await fakeTime.nextAsync(); + + const response = await requestPromise; + + expect(response.status).toBe(200); + expect(mockHttpClient.requests.length).toBe(2); + + // Verify debug logger was called with correct retry message + expect(debugStub.calls.length).toBe(1); + expect(debugStub.calls[0].args[0]).toBe( + 'Request failed with status 429, retrying in 1000ms (attempt 1/3)', + ); + } finally { + debugStub.restore(); + } + } finally { + fakeTime.restore(); + } + }); + + await t.step('logs error when token refresh fails', async () => { + const { mockHttpClient, requestHandler, mockLogger } = createRequestHandlerTestSetup({ + accessToken: 'expired-token', + refreshToken: 'invalid-refresh-token', + clientId: 'test-client-id', + responses: [ + mockUnauthorizedResponse, + { status: 400, headers: {}, body: { error: 'invalid_grant' } }, + ], + }); + + // Stub the error method to capture calls + const errorStub = stub(mockLogger, 'error', () => {}); + + try { + let thrownError: unknown; + try { + await requestHandler.get({ path: '/test' }); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(XmApiError); + expect(mockHttpClient.requests.length).toBe(2); // Initial 401 + failed token refresh + + // Verify error logger was called + expect(errorStub.calls.length).toBe(1); + expect(errorStub.calls[0].args[0]).toBe('Failed to refresh token:'); + expect(errorStub.calls[0].args[1]).toBeInstanceOf(XmApiError); + } finally { + errorStub.restore(); + mockHttpClient.reset(); + } + }); + + await t.step('logs warning when onTokenRefresh callback throws error', async () => { + const throwingCallback = () => { + throw new Error('Callback error'); + }; + + const { mockHttpClient, mockLogger } = createRequestHandlerTestSetup({ + accessToken: 'expired-token', + refreshToken: 'valid-refresh-token', + clientId: 'test-client-id', + responses: [mockUnauthorizedResponse, mockTokenRefreshResponse, mockSuccessResponse], + }); + + // Override the options to include the throwing callback + const requestHandlerWithCallback = new RequestHandler({ + hostname: 'https://example.xmatters.com', + accessToken: 'expired-token', + refreshToken: 'valid-refresh-token', + clientId: 'test-client-id', + maxRetries: 3, + httpClient: mockHttpClient, + logger: mockLogger, + onTokenRefresh: throwingCallback, + }); + + // Stub the warn method to capture calls + const warnStub = stub(mockLogger, 'warn', () => {}); + + try { + const response = await requestHandlerWithCallback.get({ path: '/test' }); + + expect(response.status).toBe(200); + expect(mockHttpClient.requests.length).toBe(3); // Initial 401 + token refresh + retry + + // Verify warning logger was called + expect(warnStub.calls.length).toBe(1); + expect(warnStub.calls[0].args[0]).toBe( + 'Error in onTokenRefresh callback, but continuing with refreshed token', + ); + expect(warnStub.calls[0].args[1]).toBeInstanceOf(Error); + expect((warnStub.calls[0].args[1] as Error).message).toBe('Callback error'); + } finally { + warnStub.restore(); + mockHttpClient.reset(); + } + }); + + await t.step('throws error when token refresh returns non-200 status', async () => { + const { mockHttpClient, requestHandler, mockLogger } = createRequestHandlerTestSetup({ + accessToken: 'expired-token', + refreshToken: 'invalid-refresh-token', + clientId: 'test-client-id', + responses: [ + mockUnauthorizedResponse, + { status: 401, headers: {}, body: { error: 'invalid_client' } }, + ], + }); + + // Stub the error method to capture calls + const errorStub = stub(mockLogger, 'error', () => {}); + + try { + let thrownError: unknown; + try { + await requestHandler.get({ path: '/test' }); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(XmApiError); + const xmError = thrownError as XmApiError; + expect(xmError.message).toBe('Failed to refresh token'); + expect(xmError.response?.status).toBe(401); + + // Verify error logger was called + expect(errorStub.calls.length).toBe(1); + expect(errorStub.calls[0].args[0]).toBe('Failed to refresh token:'); + expect(errorStub.calls[0].args[1]).toBeInstanceOf(XmApiError); + } finally { + errorStub.restore(); + mockHttpClient.reset(); + } + }); + + await t.step('logs debug message with exponential backoff delay on server errors', async () => { + const fakeTime = new FakeTime(); + try { + const { mockHttpClient, requestHandler, mockLogger } = createRequestHandlerTestSetup({ + responses: [mockServerErrorResponse, mockSuccessResponse], + }); + + // Stub the debug method to capture calls + const debugStub = stub(mockLogger, 'debug', () => {}); + + try { + const requestPromise = requestHandler.get({ path: '/test' }); + + // Allow the first request to complete and set up the timer + await fakeTime.nextAsync(); + // Now advance time to trigger the retry + await fakeTime.nextAsync(); + + const response = await requestPromise; + + expect(response.status).toBe(200); + expect(mockHttpClient.requests.length).toBe(2); + + // Verify debug logger was called with exponential backoff message + expect(debugStub.calls.length).toBe(1); + expect(debugStub.calls[0].args[0]).toBe( + 'Request failed with status 503, retrying in 1000ms (attempt 1/3)', + ); + } finally { + debugStub.restore(); + } + } finally { + fakeTime.restore(); + } + }); + + await t.step('respects Retry-After header and logs correct delay', async () => { + const fakeTime = new FakeTime(); + try { + const customRateLimitResponse: HttpResponse = { + status: 429, + headers: { 'retry-after': '5' }, // 5 seconds + body: { message: 'Too many requests' }, + }; + + const { mockHttpClient, requestHandler, mockLogger } = createRequestHandlerTestSetup({ + responses: [customRateLimitResponse, mockSuccessResponse], + }); + + // Stub the debug method to capture calls + const debugStub = stub(mockLogger, 'debug', () => {}); + + try { + const requestPromise = requestHandler.get({ path: '/test' }); + + // Allow the first request to complete and set up the timer + await fakeTime.nextAsync(); + // Now advance time to trigger the retry + await fakeTime.nextAsync(); + + const response = await requestPromise; + + expect(response.status).toBe(200); + expect(mockHttpClient.requests.length).toBe(2); + + // Verify debug logger was called with Retry-After header value + expect(debugStub.calls.length).toBe(1); + expect(debugStub.calls[0].args[0]).toBe( + 'Request failed with status 429, retrying in 5000ms (attempt 1/3)', + ); + } finally { + debugStub.restore(); + } + } finally { + fakeTime.restore(); + } + }); }); diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index 5f072d8..4d7aa27 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -39,7 +39,7 @@ export class RequestHandler { if (isOAuthOptions(options)) { this.tokenState = { accessToken: options.accessToken, - refreshToken: options.refreshToken || '', + refreshToken: options.refreshToken, clientId: options.clientId, // Set a default expiry 5 minutes from now - we'll get the real value on first refresh expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), @@ -68,17 +68,23 @@ export class RequestHandler { * 3. Execute the onTokenRefresh callback if provided (with error handling) * * @param tokenResponse - The token response object from the xMatters API - * @param clientId - Optional client ID for OAuth2 operations (preserved from previous state if not provided) + * @param clientId - Optional client ID for OAuth2 operations (preserved from current state if not provided) */ async handleNewTokens( tokenResponse: OAuth2TokenResponse, clientId?: string, ): Promise { + // Use provided clientId or fall back to current state's clientId + const finalClientId = clientId ?? this.tokenState?.clientId; + if (!finalClientId) { + throw new XmApiError('Client ID is required for token handling'); + } + // Update token state this.tokenState = { accessToken: tokenResponse.access_token, refreshToken: tokenResponse.refresh_token, - clientId: clientId ?? this.tokenState?.clientId, + clientId: finalClientId, expiresAt: new Date(Date.now() + (tokenResponse.expires_in * 1000)).toISOString(), scopes: tokenResponse.scope?.split(' ') ?? [], }; @@ -105,20 +111,16 @@ export class RequestHandler { private async refreshToken(): Promise { try { - if (!this.tokenState?.refreshToken) { - throw new XmApiError('No refresh token available for token refresh'); + if (!this.tokenState) { + throw new XmApiError('No token state available for token refresh'); } const params = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: this.tokenState.refreshToken, + client_id: this.tokenState.clientId, }); - // Add client ID if available (required for some OAuth2 servers) - if (this.tokenState.clientId) { - params.append('client_id', this.tokenState.clientId); - } - const refreshRequest = this.requestBuilder.build({ method: 'POST', path: '/oauth2/token', diff --git a/src/core/resource-client.test.ts b/src/core/resource-client.test.ts new file mode 100644 index 0000000..60c4a1b --- /dev/null +++ b/src/core/resource-client.test.ts @@ -0,0 +1,212 @@ +import { expect } from 'https://deno.land/std@0.224.0/expect/mod.ts'; +import { ResourceClient } from './resource-client.ts'; +import { RequestHandler } from './request-handler.ts'; +import { XmApiError } from './errors.ts'; +import type { HttpRequest, HttpResponse } from './types/internal/http.ts'; + +// Mock HTTP client for testing +class MockHttpClient { + private responses: HttpResponse[] = []; + private requestHistory: HttpRequest[] = []; + addResponse(response: HttpResponse) { + this.responses.push(response); + } + getRequestHistory(): HttpRequest[] { + return this.requestHistory; + } + send(request: HttpRequest): Promise { + this.requestHistory.push(request); + if (this.responses.length === 0) { + throw new Error('MockHttpClient: No more responses configured'); + } + return Promise.resolve(this.responses.shift()!); + } +} + +// Helper to create ResourceClient with mock dependencies +function createResourceClientTestSetup(basePath: string) { + const mockHttpClient = new MockHttpClient(); + const requestHandler = new RequestHandler({ + httpClient: mockHttpClient, + hostname: 'https://test.xmatters.com', + username: 'testuser', + password: 'testpass', + }); + return { + mockHttpClient, + requestHandler, + createResourceClient: () => new ResourceClient(requestHandler, basePath), + }; +} + +Deno.test('ResourceClient', async (t) => { + await t.step( + 'Constructor validation - throws XmApiError when base path does not start with slash', + () => { + const { requestHandler } = createResourceClientTestSetup('/valid'); + let thrownError: unknown; + try { + new ResourceClient(requestHandler, 'invalid-path'); + } catch (error) { + thrownError = error; + } + expect(thrownError).toBeInstanceOf(XmApiError); + const error = thrownError as XmApiError; + expect(error.message).toBe('Base path must start with a /'); + expect(error.response).toBeUndefined(); // This is a validation error, not an HTTP error + }, + ); + + await t.step('Constructor validation - accepts valid base path starting with slash', () => { + const { createResourceClient } = createResourceClientTestSetup('/groups'); + expect(() => createResourceClient()).not.toThrow(); + }); + + await t.step('get() - prepends base path to relative path', async () => { + const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); + const client = createResourceClient(); + // Mock successful response + mockHttpClient.addResponse({ + status: 200, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ success: true }), + }); + await client.get({ path: 'members' }); + const requests = mockHttpClient.getRequestHistory(); + expect(requests).toHaveLength(1); + expect(requests[0].path).toBe('/groups/members'); + }); + + await t.step('get() - uses base path when no path provided', async () => { + const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); + const client = createResourceClient(); + mockHttpClient.addResponse({ + status: 200, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ success: true }), + }); + await client.get({}); + const requests = mockHttpClient.getRequestHistory(); + expect(requests).toHaveLength(1); + expect(requests[0].path).toBe('/groups'); + }); + + await t.step('get() - strips leading slash from provided path', async () => { + const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); + const client = createResourceClient(); + mockHttpClient.addResponse({ + status: 200, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ success: true }), + }); + await client.get({ path: '/members' }); + const requests = mockHttpClient.getRequestHistory(); + expect(requests).toHaveLength(1); + expect(requests[0].path).toBe('/groups/members'); + }); + + await t.step('post() - prepends base path correctly', async () => { + const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); + const client = createResourceClient(); + mockHttpClient.addResponse({ + status: 201, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: '123' }), + }); + await client.post({ + path: 'new-group', + body: { name: 'Test Group' }, + }); + const requests = mockHttpClient.getRequestHistory(); + expect(requests).toHaveLength(1); + expect(requests[0].path).toBe('/groups/new-group'); + expect(requests[0].method).toBe('POST'); + }); + + await t.step('put() - prepends base path correctly', async () => { + const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); + const client = createResourceClient(); + mockHttpClient.addResponse({ + status: 200, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: '123' }), + }); + await client.put({ + path: '123', + body: { name: 'Updated Group' }, + }); + const requests = mockHttpClient.getRequestHistory(); + expect(requests).toHaveLength(1); + expect(requests[0].path).toBe('/groups/123'); + expect(requests[0].method).toBe('PUT'); + }); + await t.step('patch() - prepends base path correctly', async () => { + const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); + const client = createResourceClient(); + mockHttpClient.addResponse({ + status: 200, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: '123' }), + }); + await client.patch({ + path: '123', + body: { name: 'Patched Group' }, + }); + const requests = mockHttpClient.getRequestHistory(); + expect(requests).toHaveLength(1); + expect(requests[0].path).toBe('/groups/123'); + expect(requests[0].method).toBe('PATCH'); + }); + + await t.step('delete() - prepends base path correctly', async () => { + const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); + const client = createResourceClient(); + mockHttpClient.addResponse({ + status: 204, + headers: {}, + body: '', + }); + await client.delete({ path: '123' }); + const requests = mockHttpClient.getRequestHistory(); + expect(requests).toHaveLength(1); + expect(requests[0].path).toBe('/groups/123'); + expect(requests[0].method).toBe('DELETE'); + }); + + await t.step('Complex path building - handles nested paths correctly', async () => { + const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); + const client = createResourceClient(); + mockHttpClient.addResponse({ + status: 200, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ success: true }), + }); + await client.get({ path: '123/members/456' }); + const requests = mockHttpClient.getRequestHistory(); + expect(requests).toHaveLength(1); + expect(requests[0].path).toBe('/groups/123/members/456'); + }); + + await t.step('Passes through all other options unchanged', async () => { + const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); + const client = createResourceClient(); + mockHttpClient.addResponse({ + status: 200, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ success: true }), + }); + const testHeaders = { 'Custom-Header': 'test-value' }; + const testQuery = { page: '1', limit: '10' }; + await client.get({ + path: 'members', + headers: testHeaders, + query: testQuery, + }); + const requests = mockHttpClient.getRequestHistory(); + expect(requests).toHaveLength(1); + // Check that custom headers are included + expect(requests[0].headers?.['Custom-Header']).toBe('test-value'); + // Check that query parameters are passed through + expect(requests[0].query).toEqual(testQuery); + }); +}); diff --git a/src/core/resource-client.ts b/src/core/resource-client.ts index 366f728..d024168 100644 --- a/src/core/resource-client.ts +++ b/src/core/resource-client.ts @@ -4,6 +4,7 @@ import type { RequestWithBodyOptions, } from './types/internal/methods.ts'; import { RequestHandler } from './request-handler.ts'; +import { XmApiError } from './errors.ts'; /** * A wrapper around RequestHandler that automatically prepends a base path to all requests. @@ -16,7 +17,7 @@ export class ResourceClient { private readonly basePath: string, ) { if (!basePath.startsWith('/')) { - throw new Error('Base path must start with a /'); + throw new XmApiError('Base path must start with a /'); } } diff --git a/src/core/types/internal/config.ts b/src/core/types/internal/config.ts index 9946e1e..08e72b0 100644 --- a/src/core/types/internal/config.ts +++ b/src/core/types/internal/config.ts @@ -58,11 +58,12 @@ export type BasicAuthCredentials = Pick< /** * Configuration options for OAuth authentication with existing tokens. + * All three fields are required for proper OAuth functionality. */ export interface XmApiOAuthOptions extends XmApiBaseOptions { accessToken: string; - refreshToken?: string; - clientId?: string; + refreshToken: string; + clientId: string; } /** diff --git a/src/core/types/internal/oauth.ts b/src/core/types/internal/oauth.ts index d12e945..ba1a778 100644 --- a/src/core/types/internal/oauth.ts +++ b/src/core/types/internal/oauth.ts @@ -27,8 +27,6 @@ export interface TokenState extends TokenData { expiresAt: string; /** Scopes granted to the token */ scopes: string[]; - /** Optional client ID used for token refresh */ - clientId?: string; } /** @@ -39,6 +37,6 @@ export interface TokenData { accessToken: string; /** Token to use for getting a new access token */ refreshToken: string; - /** Optional client ID used for OAuth2 server authentication */ - clientId?: string; + /** Client ID used for OAuth2 server authentication */ + clientId: string; } diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index f4bcb6a..75ccff8 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -65,8 +65,10 @@ function createEndpointTestSetup(options: { send: () => Promise.resolve({ status: 200, headers: {}, body: {} }), }; - const mockOptions: XmApiOptions = accessToken - ? { + let mockOptions: XmApiOptions; + if (accessToken && refreshToken && clientId) { + // OAuth configuration - all three are required + mockOptions = { hostname, accessToken, refreshToken, @@ -75,8 +77,10 @@ function createEndpointTestSetup(options: { maxRetries, httpClient: mockHttpClient, logger: mockLogger, - } - : { + }; + } else { + // Basic auth configuration + mockOptions = { hostname, username, password, @@ -85,6 +89,7 @@ function createEndpointTestSetup(options: { httpClient: mockHttpClient, logger: mockLogger, }; + } const requestHandler = new RequestHandler(mockOptions); const endpoint = new GroupsEndpoint(requestHandler); @@ -274,6 +279,8 @@ Deno.test('GroupsEndpoint', async (t) => { await t.step('OAuth authentication - sends correct Authorization header', async () => { const { mockHttpClient, endpoint } = createEndpointTestSetup({ accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + clientId: 'test-client-id', hostname: 'https://oauth.xmatters.com', }); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); diff --git a/src/endpoints/oauth/index.test.ts b/src/endpoints/oauth/index.test.ts index 782c5be..f955fc1 100644 --- a/src/endpoints/oauth/index.test.ts +++ b/src/endpoints/oauth/index.test.ts @@ -37,16 +37,6 @@ class MockHttpClient implements HttpClient { this.callIndex++; return Promise.resolve(response as HttpResponse); } - - reset() { - this.requests = []; - this.callIndex = 0; - } - - setResponses(responses: HttpResponse[]) { - this.responses = responses; - this.callIndex = 0; - } } /** @@ -85,7 +75,8 @@ function createTestRequestHandler(options: { // Create auth options based on provided parameters let mockOptions: XmApiOptions; - if (accessToken) { + if (accessToken && refreshToken && clientId) { + // OAuth configuration - all three are required mockOptions = { hostname, accessToken, @@ -173,14 +164,14 @@ Deno.test('OAuthEndpoint - getTokensByCredentials() - successful token acquisiti Deno.test('OAuthEndpoint - getTokensByCredentials() - throws error when no constructor credentials', async () => { const { requestHandler, mockHttpClient: _ } = createTestRequestHandler({ - accessToken: 'existing-token', // OAuth mode, no basic auth + // No credentials provided - this will default to basic auth mode but with missing fields responses: [], }); const oauthEndpoint = new OAuthEndpoint(requestHandler); await expect(oauthEndpoint.getTokensByCredentials()).rejects.toThrow( - 'XmApi must be initialized with basic auth credentials (username, password, clientId) to acquire OAuth tokens.', + 'clientId is required for OAuth token acquisition. Provide it in the XmApi constructor.', ); // Verify no HTTP request was made diff --git a/src/index.ts b/src/index.ts index e4e605f..9e2f97b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,8 +25,9 @@ import { OAuthEndpoint } from './endpoints/oauth/index.ts'; * ```typescript * const xm = new XmApi({ * hostname: 'https://example.xmatters.com', - * accessToken: 'your-token', + * accessToken: 'your-access-token', * refreshToken: 'your-refresh-token', + * clientId: 'your-client-id', * // Optional configurations * httpClient: myCustomHttpClient, * logger: myCustomLogger, From ad6b0f4f9091c0876cb57b45be5efccd70e1c282 Mon Sep 17 00:00:00 2001 From: johan Date: Mon, 9 Jun 2025 17:16:37 -0700 Subject: [PATCH 027/101] Make time manipulating tests clearer with inline comments and explicit assertions --- src/core/request-handler.test.ts | 54 +++++++++++++++++++++++++++--- src/endpoints/groups/index.test.ts | 38 ++++++++++++++++++--- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/src/core/request-handler.test.ts b/src/core/request-handler.test.ts index ed6ab2c..343fd99 100644 --- a/src/core/request-handler.test.ts +++ b/src/core/request-handler.test.ts @@ -178,13 +178,23 @@ Deno.test('RequestHandler', async (t) => { responses: [mockRateLimitResponse, mockSuccessResponse], }); + // Start the async request but DON'T await it yet + // This begins the async chain but allows us to control timing with FakeTime const requestPromise = requestHandler.get({ path: '/test' }); // Allow the first request to complete and set up the timer + // This advances fake time to let the setTimeout callback fire await fakeTime.nextAsync(); - // Now advance time to trigger the retry + // Verify the first request completed and retry was triggered + // At this point: initial request failed → setTimeout set → timeout fired → retry executed + expect(mockHttpClient.requests.length).toBe(2); + expect(mockHttpClient.requests[0].retryAttempt).toBe(0); + expect(mockHttpClient.requests[1].retryAttempt).toBe(1); + // Now advance time to trigger any additional timers (should be none) await fakeTime.nextAsync(); + // Finally await the original promise to get the result + // By now all async operations have completed thanks to our time control const response = await requestPromise; expect(response.status).toBe(200); @@ -211,13 +221,23 @@ Deno.test('RequestHandler', async (t) => { responses: [mockServerErrorResponse, mockSuccessResponse], }); + // Start the async request but DON'T await it yet + // This begins the async chain but allows us to control timing with FakeTime const requestPromise = requestHandler.get({ path: '/test' }); // Allow the first request to complete and set up the timer + // This advances fake time to let the setTimeout callback fire await fakeTime.nextAsync(); - // Now advance time to trigger the retry + // Verify the first request completed and retry was triggered + // At this point: initial request failed → setTimeout set → timeout fired → retry executed + expect(mockHttpClient.requests.length).toBe(2); + expect(mockHttpClient.requests[0].retryAttempt).toBe(0); + expect(mockHttpClient.requests[1].retryAttempt).toBe(1); + // Now advance time to trigger any additional timers (should be none) await fakeTime.nextAsync(); + // Finally await the original promise to get the result + // By now all async operations have completed thanks to our time control const response = await requestPromise; expect(response.status).toBe(200); @@ -401,13 +421,23 @@ Deno.test('RequestHandler', async (t) => { const debugStub = stub(mockLogger, 'debug', () => {}); try { + // Start the async request but DON'T await it yet + // This begins the async chain but allows us to control timing with FakeTime const requestPromise = requestHandler.get({ path: '/test' }); // Allow the first request to complete and set up the timer + // This advances fake time to let the setTimeout callback fire await fakeTime.nextAsync(); - // Now advance time to trigger the retry + // Verify the first request completed and retry was triggered + // At this point: initial request failed → setTimeout set → timeout fired → retry executed + expect(mockHttpClient.requests.length).toBe(2); + expect(mockHttpClient.requests[0].retryAttempt).toBe(0); + expect(mockHttpClient.requests[1].retryAttempt).toBe(1); + // Now advance time to trigger any additional timers (should be none) await fakeTime.nextAsync(); + // Finally await the original promise to get the result + // By now all async operations have completed thanks to our time control const response = await requestPromise; expect(response.status).toBe(200); @@ -555,13 +585,23 @@ Deno.test('RequestHandler', async (t) => { const debugStub = stub(mockLogger, 'debug', () => {}); try { + // Start the async request but DON'T await it yet + // This begins the async chain but allows us to control timing with FakeTime const requestPromise = requestHandler.get({ path: '/test' }); // Allow the first request to complete and set up the timer + // This advances fake time to let the setTimeout callback fire await fakeTime.nextAsync(); - // Now advance time to trigger the retry + // Verify the first request completed and retry was triggered + // At this point: initial request failed → setTimeout set → timeout fired → retry executed + expect(mockHttpClient.requests.length).toBe(2); + expect(mockHttpClient.requests[0].retryAttempt).toBe(0); + expect(mockHttpClient.requests[1].retryAttempt).toBe(1); + // Now advance time to trigger any additional timers (should be none) await fakeTime.nextAsync(); + // Finally await the original promise to get the result + // By now all async operations have completed thanks to our time control const response = await requestPromise; expect(response.status).toBe(200); @@ -601,7 +641,11 @@ Deno.test('RequestHandler', async (t) => { // Allow the first request to complete and set up the timer await fakeTime.nextAsync(); - // Now advance time to trigger the retry + // Verify the first request completed and retry was triggered + expect(mockHttpClient.requests.length).toBe(2); + expect(mockHttpClient.requests[0].retryAttempt).toBe(0); + expect(mockHttpClient.requests[1].retryAttempt).toBe(1); + // Now advance time to trigger any additional timers (should be none) await fakeTime.nextAsync(); const response = await requestPromise; diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index 75ccff8..18d6c2b 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -483,12 +483,27 @@ Deno.test('GroupsEndpoint', async (t) => { }); const debugStub = stub(mockLogger, 'debug', () => {}); try { - // Start the request + // Start the async request but DON'T await it yet + // This begins the async chain but allows us to control timing with FakeTime const requestPromise = endpoint.getGroups(); + // Allow the first request to complete and set up the timer + // This advances fake time to let the setTimeout callback fire await fakeTime.nextAsync(); - // Now advance time to trigger the retry + + // Verify the first request completed and retry was triggered + // At this point: initial request failed → setTimeout set → timeout fired → retry executed + expect(sendStub.calls.length).toBe(2); + expect(sendStub.calls[0].args[0].method).toBe('GET'); + expect(sendStub.calls[0].args[0].path).toBe('/groups'); + expect(sendStub.calls[1].args[0].method).toBe('GET'); + expect(sendStub.calls[1].args[0].path).toBe('/groups'); + + // Now advance time to trigger any additional timers (should be none) await fakeTime.nextAsync(); + + // Finally await the original promise to get the result + // By now all async operations have completed thanks to our time control const response = await requestPromise; expect(response.body).toEqual(successResponse.body); expect(sendStub.calls.length).toBe(2); @@ -535,12 +550,27 @@ Deno.test('GroupsEndpoint', async (t) => { }); const debugStub = stub(mockLogger, 'debug', () => {}); try { - // Start the request + // Start the async request but DON'T await it yet + // This begins the async chain but allows us to control timing with FakeTime const requestPromise = endpoint.getGroups(); + // Allow the first request to complete and set up the timer + // This advances fake time to let the setTimeout callback fire await fakeTime.nextAsync(); - // Now advance time to trigger the retry + + // Verify the first request completed and retry was triggered + // At this point: initial request failed → setTimeout set → timeout fired → retry executed + expect(sendStub.calls.length).toBe(2); + expect(sendStub.calls[0].args[0].method).toBe('GET'); + expect(sendStub.calls[0].args[0].path).toBe('/groups'); + expect(sendStub.calls[1].args[0].method).toBe('GET'); + expect(sendStub.calls[1].args[0].path).toBe('/groups'); + + // Now advance time to trigger any additional timers (should be none) await fakeTime.nextAsync(); + + // Finally await the original promise to get the result + // By now all async operations have completed thanks to our time control const response = await requestPromise; expect(response.body).toEqual(successResponse.body); expect(sendStub.calls.length).toBe(2); From ab8eda60dcf0bb14fe98649ca7180171f8c80bc4 Mon Sep 17 00:00:00 2001 From: johan Date: Mon, 9 Jun 2025 18:17:20 -0700 Subject: [PATCH 028/101] Fix the HttpRequest type --- src/core/request-builder.test.ts | 49 ++++++++++++------- src/core/request-builder.ts | 20 ++++++-- src/core/request-handler.test.ts | 75 ++++++++++++++++++++++++++++-- src/core/request-handler.ts | 6 ++- src/core/resource-client.test.ts | 21 +++++---- src/core/types/internal/http.ts | 16 +++---- src/endpoints/groups/index.test.ts | 38 ++++----------- src/endpoints/oauth/index.test.ts | 2 +- 8 files changed, 152 insertions(+), 75 deletions(-) diff --git a/src/core/request-builder.test.ts b/src/core/request-builder.test.ts index faa1826..5d03009 100644 --- a/src/core/request-builder.test.ts +++ b/src/core/request-builder.test.ts @@ -50,12 +50,10 @@ Deno.test('RequestBuilder', async (t) => { const { builder } = createRequestBuilderTestSetup(); const request = builder.build(mockRelativePathOptions); expect(request.url).toBe('https://example.xmatters.com/api/xm/1/people?search=test&limit=10'); - expect(request.path).toBe('/people'); expect(request.method).toBe('GET'); expect(request.headers?.['Content-Type']).toBe('application/json'); expect(request.headers?.['Accept']).toBe('application/json'); expect(request.headers?.['default-header']).toBe('default-value'); - expect(request.query).toEqual({ search: 'test', limit: 10 }); expect(request.retryAttempt).toBe(0); }); @@ -63,12 +61,10 @@ Deno.test('RequestBuilder', async (t) => { const { builder } = createRequestBuilderTestSetup(); const request = builder.build(mockExternalUrlOptions); expect(request.url).toBe('https://api.external-service.com/v2/endpoint?key=value'); - expect(request.path).toBe('https://api.external-service.com/v2/endpoint'); expect(request.method).toBe('POST'); expect(request.headers?.['Content-Type']).toBe('application/json'); expect(request.headers?.['Accept']).toBe('application/json'); expect(request.headers?.['Authorization']).toBe('Bearer token'); - expect(request.query).toEqual({ key: 'value' }); }); await t.step('preserves existing query parameters in external URLs', () => { @@ -83,9 +79,6 @@ Deno.test('RequestBuilder', async (t) => { expect(url.searchParams.get('another')).toBe('value'); expect(url.searchParams.get('additional')).toBe('param'); expect(url.searchParams.get('new')).toBe('value'); - expect(request.path).toBe( - 'https://api.external-service.com/search?existing=param&another=value', - ); }); await t.step('merges headers correctly - request headers override defaults', () => { @@ -106,7 +99,6 @@ Deno.test('RequestBuilder', async (t) => { }; const request = builder.build(options); expect(request.method).toBe('GET'); - expect(request.path).toBe('/users'); expect(request.url).toBe('https://example.xmatters.com/api/xm/1/users'); }); @@ -118,7 +110,6 @@ Deno.test('RequestBuilder', async (t) => { }; const request = builder.build(options); expect(request.url).toBe('https://example.xmatters.com/api/xm/1/devices'); - expect(request.query).toEqual({}); }); await t.step('filters out null and undefined query parameters', () => { @@ -149,7 +140,6 @@ Deno.test('RequestBuilder', async (t) => { }; const request = builder.build(options); expect(request.url).toBe('https://custom.xmatters.com/api/xm/1/notifications'); - expect(request.path).toBe('/notifications'); }); await t.step('works with empty default headers', () => { @@ -248,13 +238,7 @@ Deno.test('RequestBuilder', async (t) => { expect(request.url).toBe( 'https://example.xmatters.com/api/xm/1/forms/abc123/submissions?status=pending&priority=high&assignee=user123', ); - expect(request.path).toBe('/forms/abc123/submissions'); expect(request.method).toBe('PATCH'); - expect(request.query).toEqual({ - status: 'pending', - priority: 'high', - assignee: 'user123', - }); expect(request.headers?.['Authorization']).toBe('Bearer access-token'); expect(request.headers?.['X-Custom-Header']).toBe('custom-value'); expect(request.headers?.['Content-Type']).toBe('application/vnd.api+json'); @@ -271,4 +255,37 @@ Deno.test('RequestBuilder', async (t) => { }); expect(request.retryAttempt).toBe(1); }); + + await t.step('integration - verifies external URL is correctly passed to HTTP client', () => { + // This test ensures that when using fullUrl, the complete external URL + // (not just the path) is properly passed to the underlying HTTP client + const { builder } = createRequestBuilderTestSetup(); + const request = builder.build({ + fullUrl: 'https://api.external-service.com/v2/endpoint', + query: { test: 'param' }, + }); + + // Verify the request.url contains the complete external URL with query params + expect(request.url).toBe('https://api.external-service.com/v2/endpoint?test=param'); + + // This ensures consumers using fullUrl to bypass xMatters API get the complete external URL + expect(request.url).not.toContain('/api/xm/1'); // Should not contain API version + expect(request.url).toContain('api.external-service.com'); // Should contain external domain + }); + + await t.step('integration - verifies API path is correctly built with base URL', () => { + // This test ensures that relative API paths are correctly combined with the base URL + const { builder } = createRequestBuilderTestSetup(); + const request = builder.build({ + path: '/groups', + query: { search: 'test' }, + }); + + // Verify the request.url contains the complete API URL + expect(request.url).toBe('https://example.xmatters.com/api/xm/1/groups?search=test'); + + // This ensures regular API calls get the proper xMatters API URL structure + expect(request.url).toContain('/api/xm/1'); // Should contain API version + expect(request.url).toContain('example.xmatters.com'); // Should contain configured hostname + }); }); diff --git a/src/core/request-builder.ts b/src/core/request-builder.ts index 6a58733..3fc88e1 100644 --- a/src/core/request-builder.ts +++ b/src/core/request-builder.ts @@ -5,7 +5,7 @@ import { XmApiError } from './errors.ts'; * Request options for building HTTP requests. * Either path or fullUrl must be provided, but not both. */ -export interface RequestBuildOptions extends Partial { +export interface RequestBuildOptions { /** * The path relative to the API version path. * Do not include the API version (/api/xm/1). @@ -20,6 +20,21 @@ export interface RequestBuildOptions extends Partial { * @example "https://api.external-service.com/v2/endpoint" */ fullUrl?: string; + + /** The HTTP method to use for the request */ + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + + /** Optional headers to send with the request */ + headers?: Record; + + /** Optional query parameters to include in the URL */ + query?: Record; + + /** Optional request body */ + body?: unknown; + + /** Used internally for retry logic */ + retryAttempt?: number; } export class RequestBuilder { @@ -64,10 +79,7 @@ export class RequestBuilder { const builtRequest: HttpRequest = { method: options.method || 'GET', - // For the path property, use the actual path provided or extract it from fullUrl - path: options.path || options.fullUrl || '', url: url.toString(), - query: options.query, headers, body: options.body, retryAttempt: options.retryAttempt || 0, diff --git a/src/core/request-handler.test.ts b/src/core/request-handler.test.ts index 343fd99..2c81a8d 100644 --- a/src/core/request-handler.test.ts +++ b/src/core/request-handler.test.ts @@ -202,12 +202,10 @@ Deno.test('RequestHandler', async (t) => { // Verify first request const firstRequest = mockHttpClient.requests[0]; - expect(firstRequest.path).toBe('/test'); expect(firstRequest.retryAttempt).toBe(0); // Verify retry request const retryRequest = mockHttpClient.requests[1]; - expect(retryRequest.path).toBe('/test'); expect(retryRequest.retryAttempt).toBe(1); } finally { fakeTime.restore(); @@ -379,7 +377,7 @@ Deno.test('RequestHandler', async (t) => { // Verify token refresh request const refreshRequest = mockHttpClient.requests[1]; - expect(refreshRequest.path).toBe('/oauth2/token'); + expect(refreshRequest.url).toBe('https://example.xmatters.com/api/xm/1/oauth2/token'); expect(refreshRequest.headers?.['Content-Type']).toBe('application/x-www-form-urlencoded'); expect(refreshRequest.body).toBeDefined(); @@ -436,7 +434,7 @@ Deno.test('RequestHandler', async (t) => { // Now advance time to trigger any additional timers (should be none) await fakeTime.nextAsync(); - // Finally await the original promise to get the result + // Finally await the original promise to get the response // By now all async operations have completed thanks to our time control const response = await requestPromise; @@ -665,4 +663,73 @@ Deno.test('RequestHandler', async (t) => { fakeTime.restore(); } }); + + await t.step( + 'integration - verifies external URL is passed correctly to HTTP client', + async () => { + // This test ensures that when using fullUrl, the external URL is properly passed + // to the HTTP client, not the xMatters API URL + const mockHttpClient = new MockHttpClient([mockSuccessResponse]); + + const requestHandler = new RequestHandler({ + hostname: 'https://company.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, + }); + + try { + await requestHandler.send({ + fullUrl: 'https://api.external-service.com/v2/endpoint', + query: { test: 'param' }, + method: 'GET', + }); + + // Verify that the HTTP client received the correct external URL + expect(mockHttpClient.requests.length).toBe(1); + const sentRequest = mockHttpClient.requests[0]; + + // The key assertion: HTTP client should receive the external URL, not the xMatters API URL + expect(sentRequest.url).toBe('https://api.external-service.com/v2/endpoint?test=param'); + expect(sentRequest.url).not.toContain('company.xmatters.com'); // Should not contain xMatters hostname + expect(sentRequest.url).not.toContain('/api/xm/1'); // Should not contain API version + } finally { + mockHttpClient.reset(); + } + }, + ); + + await t.step('integration - verifies API path is passed correctly to HTTP client', async () => { + // This test ensures that relative API paths result in correct xMatters API URLs + // being passed to the HTTP client + const mockHttpClient = new MockHttpClient([mockSuccessResponse]); + + const requestHandler = new RequestHandler({ + hostname: 'https://company.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, + }); + + try { + await requestHandler.send({ + path: '/groups', + query: { search: 'test' }, + method: 'GET', + }); + + // Verify that the HTTP client received the correct xMatters API URL + expect(mockHttpClient.requests.length).toBe(1); + const sentRequest = mockHttpClient.requests[0]; + + // The key assertion: HTTP client should receive the full xMatters API URL + expect(sentRequest.url).toBe('https://company.xmatters.com/api/xm/1/groups?search=test'); + expect(sentRequest.url).toContain('company.xmatters.com'); // Should contain xMatters hostname + expect(sentRequest.url).toContain('/api/xm/1'); // Should contain API version + } finally { + mockHttpClient.reset(); + } + }); }); diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index 4d7aa27..9c3aa44 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -168,10 +168,14 @@ export class RequestHandler { } async send( - request: Partial & { + request: { path?: string; fullUrl?: string; method?: HttpRequest['method']; + headers?: Record; + query?: Record; + body?: unknown; + retryAttempt?: number; skipAuth?: boolean; }, ): Promise> { diff --git a/src/core/resource-client.test.ts b/src/core/resource-client.test.ts index 60c4a1b..02503fe 100644 --- a/src/core/resource-client.test.ts +++ b/src/core/resource-client.test.ts @@ -74,7 +74,7 @@ Deno.test('ResourceClient', async (t) => { await client.get({ path: 'members' }); const requests = mockHttpClient.getRequestHistory(); expect(requests).toHaveLength(1); - expect(requests[0].path).toBe('/groups/members'); + expect(requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/members'); }); await t.step('get() - uses base path when no path provided', async () => { @@ -88,7 +88,7 @@ Deno.test('ResourceClient', async (t) => { await client.get({}); const requests = mockHttpClient.getRequestHistory(); expect(requests).toHaveLength(1); - expect(requests[0].path).toBe('/groups'); + expect(requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups'); }); await t.step('get() - strips leading slash from provided path', async () => { @@ -102,7 +102,7 @@ Deno.test('ResourceClient', async (t) => { await client.get({ path: '/members' }); const requests = mockHttpClient.getRequestHistory(); expect(requests).toHaveLength(1); - expect(requests[0].path).toBe('/groups/members'); + expect(requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/members'); }); await t.step('post() - prepends base path correctly', async () => { @@ -119,7 +119,7 @@ Deno.test('ResourceClient', async (t) => { }); const requests = mockHttpClient.getRequestHistory(); expect(requests).toHaveLength(1); - expect(requests[0].path).toBe('/groups/new-group'); + expect(requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/new-group'); expect(requests[0].method).toBe('POST'); }); @@ -137,7 +137,7 @@ Deno.test('ResourceClient', async (t) => { }); const requests = mockHttpClient.getRequestHistory(); expect(requests).toHaveLength(1); - expect(requests[0].path).toBe('/groups/123'); + expect(requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/123'); expect(requests[0].method).toBe('PUT'); }); await t.step('patch() - prepends base path correctly', async () => { @@ -154,7 +154,7 @@ Deno.test('ResourceClient', async (t) => { }); const requests = mockHttpClient.getRequestHistory(); expect(requests).toHaveLength(1); - expect(requests[0].path).toBe('/groups/123'); + expect(requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/123'); expect(requests[0].method).toBe('PATCH'); }); @@ -169,7 +169,7 @@ Deno.test('ResourceClient', async (t) => { await client.delete({ path: '123' }); const requests = mockHttpClient.getRequestHistory(); expect(requests).toHaveLength(1); - expect(requests[0].path).toBe('/groups/123'); + expect(requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/123'); expect(requests[0].method).toBe('DELETE'); }); @@ -184,7 +184,7 @@ Deno.test('ResourceClient', async (t) => { await client.get({ path: '123/members/456' }); const requests = mockHttpClient.getRequestHistory(); expect(requests).toHaveLength(1); - expect(requests[0].path).toBe('/groups/123/members/456'); + expect(requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/123/members/456'); }); await t.step('Passes through all other options unchanged', async () => { @@ -206,7 +206,8 @@ Deno.test('ResourceClient', async (t) => { expect(requests).toHaveLength(1); // Check that custom headers are included expect(requests[0].headers?.['Custom-Header']).toBe('test-value'); - // Check that query parameters are passed through - expect(requests[0].query).toEqual(testQuery); + expect(requests[0].url).toBe( + 'https://test.xmatters.com/api/xm/1/groups/members?page=1&limit=10', + ); }); }); diff --git a/src/core/types/internal/http.ts b/src/core/types/internal/http.ts index 9d737a3..aa7c47c 100644 --- a/src/core/types/internal/http.ts +++ b/src/core/types/internal/http.ts @@ -17,22 +17,20 @@ export interface HttpResponse { } /** - * Represents an HTTP request to be sent to the xMatters API. + * Represents a fully-prepared HTTP request ready to be sent. + * This interface is designed to work with any HTTP client implementation. + * All URL construction, query parameter handling, and header preparation has been completed. */ export interface HttpRequest { /** The HTTP method to use for the request */ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; - /** The complete URL for the request */ + /** The complete, fully-qualified URL ready for the HTTP client to use */ url: string; - /** The path portion of the URL, used for testing and debugging */ - path: string; - /** Optional headers to send with the request */ + /** Headers to send with the request (includes auth, content-type, etc.) */ headers?: Record; - /** Optional query parameters to include in the URL */ - query?: Record; - /** Optional request body */ + /** Optional request body (already serialized if needed) */ body?: unknown; - /** Used internally for retry logic */ + /** Current retry attempt number (for logging/debugging by HTTP clients) */ retryAttempt?: number; } diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index 18d6c2b..536a755 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -153,9 +153,7 @@ Deno.test('GroupsEndpoint', async (t) => { // Verify the request details const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); - expect(sentRequest.path).toBe('/groups'); expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups'); - expect(sentRequest.query).toBeUndefined(); expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); expect(sentRequest.headers?.['Accept']).toBe('application/json'); expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); @@ -174,11 +172,9 @@ Deno.test('GroupsEndpoint', async (t) => { const response = await endpoint.getGroups({ limit: 10, offset: 20 }); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); - expect(sentRequest.path).toBe('/groups'); expect(sentRequest.url).toBe( 'https://example.xmatters.com/api/xm/1/groups?limit=10&offset=20', ); - expect(sentRequest.query).toEqual({ limit: 10, offset: 20 }); expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); expect(sentRequest.headers?.['Accept']).toBe('application/json'); expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); @@ -196,11 +192,9 @@ Deno.test('GroupsEndpoint', async (t) => { const response = await endpoint.getGroups({ search: 'oncall', limit: 5 }); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); - expect(sentRequest.path).toBe('/groups'); expect(sentRequest.url).toBe( 'https://example.xmatters.com/api/xm/1/groups?search=oncall&limit=5', ); - expect(sentRequest.query).toEqual({ search: 'oncall', limit: 5 }); expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); expect(sentRequest.headers?.['Accept']).toBe('application/json'); expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); @@ -219,9 +213,7 @@ Deno.test('GroupsEndpoint', async (t) => { expect(sendStub.calls.length).toBe(1); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); - expect(sentRequest.path).toBe('/groups/test-group-123'); expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups/test-group-123'); - expect(sentRequest.query).toBeUndefined(); expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); expect(sentRequest.headers?.['Accept']).toBe('application/json'); expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); @@ -245,9 +237,7 @@ Deno.test('GroupsEndpoint', async (t) => { expect(sendStub.calls.length).toBe(1); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('POST'); - expect(sentRequest.path).toBe('/groups'); expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups'); - expect(sentRequest.query).toBeUndefined(); expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); expect(sentRequest.headers?.['Accept']).toBe('application/json'); expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); @@ -266,9 +256,7 @@ Deno.test('GroupsEndpoint', async (t) => { expect(sendStub.calls.length).toBe(1); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('DELETE'); - expect(sentRequest.path).toBe('/groups/test-group-123'); expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups/test-group-123'); - expect(sentRequest.query).toBeUndefined(); expect(sentRequest.body).toBeUndefined(); expect(response).toEqual(mockEmptyResponse); } finally { @@ -289,9 +277,7 @@ Deno.test('GroupsEndpoint', async (t) => { expect(sendStub.calls.length).toBe(1); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); - expect(sentRequest.path).toBe('/groups'); expect(sentRequest.url).toBe('https://oauth.xmatters.com/api/xm/1/groups'); - expect(sentRequest.query).toBeUndefined(); expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); expect(sentRequest.headers?.['Accept']).toBe('application/json'); expect(sentRequest.headers?.['Authorization']).toBe('Bearer test-access-token'); @@ -357,9 +343,7 @@ Deno.test('GroupsEndpoint', async (t) => { expect(sendStub.calls.length).toBe(1); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); - expect(sentRequest.path).toBe('/groups'); expect(sentRequest.url).toBe('https://custom.xmatters.com/api/xm/1/groups'); - expect(sentRequest.query).toBeUndefined(); expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); expect(sentRequest.headers?.['Accept']).toBe('application/json'); expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); @@ -381,9 +365,7 @@ Deno.test('GroupsEndpoint', async (t) => { expect(sendStub.calls.length).toBe(1); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); - expect(sentRequest.path).toBe('/groups'); expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups'); - expect(sentRequest.query).toBeUndefined(); expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); expect(sentRequest.headers?.['Accept']).toBe('application/json'); // Verify the basic auth header @@ -418,9 +400,7 @@ Deno.test('GroupsEndpoint', async (t) => { expect(sendStub.calls.length).toBe(1); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('POST'); - expect(sentRequest.path).toBe('/groups'); expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups'); - expect(sentRequest.query).toBeUndefined(); expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); expect(sentRequest.headers?.['Accept']).toBe('application/json'); expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); @@ -445,11 +425,9 @@ Deno.test('GroupsEndpoint', async (t) => { expect(sendStub.calls.length).toBe(1); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); - expect(sentRequest.path).toBe('/groups'); expect(sentRequest.url).toBe( 'https://example.xmatters.com/api/xm/1/groups?limit=25&offset=50&search=test+search', ); - expect(sentRequest.query).toEqual(params); expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); expect(sentRequest.headers?.['Accept']).toBe('application/json'); expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); @@ -495,9 +473,9 @@ Deno.test('GroupsEndpoint', async (t) => { // At this point: initial request failed → setTimeout set → timeout fired → retry executed expect(sendStub.calls.length).toBe(2); expect(sendStub.calls[0].args[0].method).toBe('GET'); - expect(sendStub.calls[0].args[0].path).toBe('/groups'); + expect(sendStub.calls[0].args[0].url).toBe('https://example.xmatters.com/api/xm/1/groups'); expect(sendStub.calls[1].args[0].method).toBe('GET'); - expect(sendStub.calls[1].args[0].path).toBe('/groups'); + expect(sendStub.calls[1].args[0].url).toBe('https://example.xmatters.com/api/xm/1/groups'); // Now advance time to trigger any additional timers (should be none) await fakeTime.nextAsync(); @@ -510,10 +488,10 @@ Deno.test('GroupsEndpoint', async (t) => { // Verify both calls were GET requests to /groups const firstRequest: HttpRequest = sendStub.calls[0].args[0]; expect(firstRequest.method).toBe('GET'); - expect(firstRequest.path).toBe('/groups'); + expect(firstRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups'); const retryRequest: HttpRequest = sendStub.calls[1].args[0]; expect(retryRequest.method).toBe('GET'); - expect(retryRequest.path).toBe('/groups'); + expect(retryRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups'); expect(debugStub.calls.length).toBe(1); expect(debugStub.calls[0].args[0]).toBe( 'Request failed with status 429, retrying in 2000ms (attempt 1/3)', @@ -562,9 +540,9 @@ Deno.test('GroupsEndpoint', async (t) => { // At this point: initial request failed → setTimeout set → timeout fired → retry executed expect(sendStub.calls.length).toBe(2); expect(sendStub.calls[0].args[0].method).toBe('GET'); - expect(sendStub.calls[0].args[0].path).toBe('/groups'); + expect(sendStub.calls[0].args[0].url).toBe('https://example.xmatters.com/api/xm/1/groups'); expect(sendStub.calls[1].args[0].method).toBe('GET'); - expect(sendStub.calls[1].args[0].path).toBe('/groups'); + expect(sendStub.calls[1].args[0].url).toBe('https://example.xmatters.com/api/xm/1/groups'); // Now advance time to trigger any additional timers (should be none) await fakeTime.nextAsync(); @@ -626,7 +604,7 @@ Deno.test('GroupsEndpoint', async (t) => { const sendStub = stub(mockHttpClient, 'send', (request: HttpRequest) => { callCount++; // Check if this is a token refresh request - if (request.path === '/oauth2/token' || request.url?.includes('/oauth2/token')) { + if (request.url?.includes('/oauth2/token') || request.url?.includes('/oauth2/token')) { return Promise.resolve(tokenRefreshResponse); } // Otherwise it's the main API request @@ -674,7 +652,7 @@ Deno.test('GroupsEndpoint', async (t) => { const sendStub = stub(mockHttpClient, 'send', (request: HttpRequest) => { callCount++; // Check if this is a token refresh request - if (request.path === '/oauth2/token' || request.url?.includes('/oauth2/token')) { + if (request.url?.includes('/oauth2/token') || request.url?.includes('/oauth2/token')) { return Promise.resolve(tokenRefreshErrorResponse); } // Otherwise it's the main API request diff --git a/src/endpoints/oauth/index.test.ts b/src/endpoints/oauth/index.test.ts index f955fc1..ef8941f 100644 --- a/src/endpoints/oauth/index.test.ts +++ b/src/endpoints/oauth/index.test.ts @@ -138,7 +138,7 @@ Deno.test('OAuthEndpoint - getTokensByCredentials() - successful token acquisiti const request = mockHttpClient.requests[0]; expect(request.method).toBe('POST'); - expect(request.path).toBe('/oauth2/token'); + expect(request.url).toBe('https://test.xmatters.com/api/xm/1/oauth2/token'); expect(request.headers?.['Content-Type']).toBe('application/x-www-form-urlencoded'); expect(request.headers?.['Accept']).toBe('application/json'); // Note: skipAuth is handled by RequestHandler.send() and not passed to the HTTP client From 01eefecaa55e189f7be4daa6cc1e72c60cb45e85 Mon Sep 17 00:00:00 2001 From: johan Date: Mon, 9 Jun 2025 18:24:33 -0700 Subject: [PATCH 029/101] Reduce types bloat --- src/core/request-builder.ts | 3 +++ src/core/request-handler.ts | 15 +++------------ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/core/request-builder.ts b/src/core/request-builder.ts index 3fc88e1..964b77b 100644 --- a/src/core/request-builder.ts +++ b/src/core/request-builder.ts @@ -35,6 +35,9 @@ export interface RequestBuildOptions { /** Used internally for retry logic */ retryAttempt?: number; + + /** Whether to skip adding authentication headers to this request */ + skipAuth?: boolean; } export class RequestBuilder { diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index 9c3aa44..2b5fa86 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -1,4 +1,4 @@ -import { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; +import { HttpClient, HttpResponse } from './types/internal/http.ts'; import { BasicAuthCredentials, isBasicAuthOptions, @@ -10,7 +10,7 @@ import { import { DeleteOptions, GetOptions, RequestWithBodyOptions } from './types/internal/methods.ts'; import { XmApiError } from './errors.ts'; import { OAuth2TokenResponse, TokenState } from './types/internal/oauth.ts'; -import { RequestBuilder } from './request-builder.ts'; +import { RequestBuilder, RequestBuildOptions } from './request-builder.ts'; import { DefaultHttpClient, defaultLogger } from './defaults/index.ts'; export class RequestHandler { @@ -168,16 +168,7 @@ export class RequestHandler { } async send( - request: { - path?: string; - fullUrl?: string; - method?: HttpRequest['method']; - headers?: Record; - query?: Record; - body?: unknown; - retryAttempt?: number; - skipAuth?: boolean; - }, + request: RequestBuildOptions, ): Promise> { // Check if token refresh is needed before making the request if (this.tokenState && this.isTokenExpired()) { From 585f1800be0a3aa518291a93d0ca2baf522321d4 Mon Sep 17 00:00:00 2001 From: johan Date: Mon, 9 Jun 2025 22:29:48 -0700 Subject: [PATCH 030/101] Minor tweaks for accuracy and organization --- src/core/defaults/http-client.ts | 31 ++++++++++++------------------ src/core/types/internal/http.ts | 2 +- src/core/types/internal/methods.ts | 8 ++++---- src/core/types/internal/oauth.ts | 22 ++++++++++----------- 4 files changed, 28 insertions(+), 35 deletions(-) diff --git a/src/core/defaults/http-client.ts b/src/core/defaults/http-client.ts index de6396e..cf88d1c 100644 --- a/src/core/defaults/http-client.ts +++ b/src/core/defaults/http-client.ts @@ -2,48 +2,41 @@ import { HttpClient, HttpRequest, HttpResponse } from '../types/internal/http.ts export class DefaultHttpClient implements HttpClient { async send(request: HttpRequest): Promise { - // Handle body serialization based on content type - let serializedBody: string | undefined; - const contentType = request.headers?.['content-type']; - if (request.body) { - if (contentType?.includes('application/json')) { - serializedBody = JSON.stringify(request.body); - } else if (typeof request.body === 'string') { - serializedBody = request.body; - } else { - // Default to JSON if no content type specified - serializedBody = JSON.stringify(request.body); - } + let serializedRequestBody: string | undefined; + if (request.body !== undefined && request.body !== null) { + serializedRequestBody = typeof request.body === 'string' + ? request.body + : JSON.stringify(request.body); } const response = await fetch(request.url, { method: request.method, headers: request.headers, - body: serializedBody, + body: serializedRequestBody, }); const headers: Record = {}; response.headers.forEach((value, key) => { - headers[key] = value; + headers[key.toLowerCase()] = value; }); - let body: unknown; + let responseBody: unknown; const responseType = headers['content-type']; if (responseType?.includes('application/json')) { try { - body = await response.json(); + responseBody = await response.json(); } catch (_e) { // If JSON parsing fails, fall back to text - body = await response.text(); + responseBody = await response.text(); } } else { - body = await response.text(); + responseBody = await response.text(); } return { status: response.status, headers, - body, + body: responseBody, }; } } diff --git a/src/core/types/internal/http.ts b/src/core/types/internal/http.ts index aa7c47c..e03e5d9 100644 --- a/src/core/types/internal/http.ts +++ b/src/core/types/internal/http.ts @@ -28,7 +28,7 @@ export interface HttpRequest { url: string; /** Headers to send with the request (includes auth, content-type, etc.) */ headers?: Record; - /** Optional request body (already serialized if needed) */ + /** Optional request body (injected HTTP client should handle serialization) */ body?: unknown; /** Current retry attempt number (for logging/debugging by HTTP clients) */ retryAttempt?: number; diff --git a/src/core/types/internal/methods.ts b/src/core/types/internal/methods.ts index ecaa4c4..20fa416 100644 --- a/src/core/types/internal/methods.ts +++ b/src/core/types/internal/methods.ts @@ -6,7 +6,7 @@ /** * Base interface for all HTTP method options */ -export interface HttpMethodOptions { +interface HttpMethodBaseOptions { /** The path portion of the URL, relative to the API version path */ path: string; /** Optional headers to send with the request */ @@ -16,7 +16,7 @@ export interface HttpMethodOptions { /** * Options for GET requests */ -export interface GetOptions extends HttpMethodOptions { +export interface GetOptions extends HttpMethodBaseOptions { /** Optional query parameters */ query?: Record; } @@ -24,7 +24,7 @@ export interface GetOptions extends HttpMethodOptions { /** * Options for POST, PUT, and PATCH requests */ -export interface RequestWithBodyOptions extends HttpMethodOptions { +export interface RequestWithBodyOptions extends HttpMethodBaseOptions { /** The request body */ body?: unknown; } @@ -32,4 +32,4 @@ export interface RequestWithBodyOptions extends HttpMethodOptions { /** * Options for DELETE requests */ -export type DeleteOptions = HttpMethodOptions; +export type DeleteOptions = HttpMethodBaseOptions; diff --git a/src/core/types/internal/oauth.ts b/src/core/types/internal/oauth.ts index ba1a778..be738fd 100644 --- a/src/core/types/internal/oauth.ts +++ b/src/core/types/internal/oauth.ts @@ -19,20 +19,10 @@ export interface OAuth2TokenResponse { scope?: string; } -/** - * Data structure for managing OAuth2 tokens with metadata and helper methods. - */ -export interface TokenState extends TokenData { - /** ISO timestamp when the access token expires */ - expiresAt: string; - /** Scopes granted to the token */ - scopes: string[]; -} - /** * Basic token data required for OAuth2 authentication. */ -export interface TokenData { +interface TokenData { /** Token to use for authenticating requests */ accessToken: string; /** Token to use for getting a new access token */ @@ -40,3 +30,13 @@ export interface TokenData { /** Client ID used for OAuth2 server authentication */ clientId: string; } + +/** + * Data structure for managing OAuth2 tokens with metadata and helper methods. + */ +export interface TokenState extends TokenData { + /** ISO timestamp when the access token expires */ + expiresAt: string; + /** Scopes granted to the token */ + scopes: string[]; +} From 0917aab243cffd2b3c21bb00014ac97d73a8e825 Mon Sep 17 00:00:00 2001 From: johan Date: Mon, 9 Jun 2025 23:50:03 -0700 Subject: [PATCH 031/101] Refine the sandbox + finally rename `getGroups` to just `get` for `groups.get()` --- docs/endpoint-patterns.md | 269 ---------------------------- docs/improvements.md | 10 +- sandbox/.env.example | 4 + sandbox/README.md | 25 +++ sandbox/credentials.ts | 33 ++++ sandbox/index.ts | 17 +- sandbox/loadEnv.ts | 39 ++++ src/core/request-handler.ts | 1 + src/core/types/endpoint/response.ts | 2 +- src/endpoints/groups/index.test.ts | 34 ++-- src/endpoints/groups/index.ts | 24 +-- src/endpoints/groups/types.ts | 22 +-- src/endpoints/oauth/index.ts | 2 +- src/index.ts | 5 +- 14 files changed, 135 insertions(+), 352 deletions(-) delete mode 100644 docs/endpoint-patterns.md create mode 100644 sandbox/.env.example create mode 100644 sandbox/README.md create mode 100644 sandbox/credentials.ts create mode 100644 sandbox/loadEnv.ts diff --git a/docs/endpoint-patterns.md b/docs/endpoint-patterns.md deleted file mode 100644 index 30f3ff0..0000000 --- a/docs/endpoint-patterns.md +++ /dev/null @@ -1,269 +0,0 @@ -# Endpoint Implementation Patterns - -This guide shows the recommended patterns for implementing new endpoints in the xMatters API -library. - -## Directory Structure - -Each endpoint should be implemented in its own directory under `src/endpoints/`: - -``` -src/endpoints/my-endpoint/ -├── index.ts # Endpoint implementation -├── types.ts # Type definitions -└── index.test.ts # Unit tests -``` - -## Type Definitions (`types.ts`) - -Follow this pattern for defining endpoint types: - -```typescript -import { PaginatedResponse } from '../../core/types/endpoint/response.ts'; -import { WithPagination, WithSearch, WithSort } from '../../core/types/endpoint/composers.ts'; - -/** - * 1. Define your main resource type - */ -export interface MyResource { - id: string; - name: string; - status: 'ACTIVE' | 'INACTIVE'; - created: string; - // ...other properties -} - -/** - * 2. Define endpoint-specific filters (if needed) - * Filters are query parameters that narrow down results beyond pagination and search. - * Only add if your endpoint supports filtering by specific field values. - */ -export interface MyResourceFilters extends Record { - status?: 'ACTIVE' | 'INACTIVE'; - category?: string; -} - -/** - * 3. Compose parameter types using composers - */ -export type GetMyResourcesParams = WithPagination>>; - -/** - * 4. Define response body types for maintainers to use (if using paginated responses) - * This represents what the API returns. Use as the generic type parameter for this.http.get. - * Not to be confused with the consumer-facing method return type - * which should be Promise>. - */ -export type GetMyResourcesResponse = PaginatedResponse; -``` - -## Endpoint Implementation (`index.ts`) - -Follow this pattern for implementing the endpoint class: - -````typescript -import { ResourceClient } from '../../core/resource-client.ts'; -import { RequestHandler } from '../../core/request-handler.ts'; -import type { HttpResponse } from '../../core/types/internal/http.ts'; -import type { - EmptyHttpResponse, - PaginatedHttpResponse, - PaginatedResponse, -} from '../../core/types/endpoint/response.ts'; -import { GetMyResourcesParams, GetMyResourcesResponse, MyResource } from './types.ts'; - -/** - * Provides access to the my-resources endpoints of the xMatters API. - * Use this class to manage resources, including listing, creating, updating, and deleting. - * - * @example - * ```typescript - * const xm = new XmApi({ - * hostname: 'https://example.xmatters.com', - * accessToken: 'your-token' - * }); - * - * // Get all resources - * const { body: resources } = await xm.myResources.getMyResources(); - * - * // Get resources with pagination - * const { body: pagedResources } = await xm.myResources.getMyResources({ - * limit: 10, - * offset: 0 - * }); - * - * // Search for resources - * const { body: searchedResources } = await xm.myResources.getMyResources({ - * search: 'keyword' - * }); - * ``` - */ -export class MyResourcesEndpoint { - private readonly http: ResourceClient; - - constructor(http: RequestHandler) { - // The base path will be automatically prepended to all requests - this.http = new ResourceClient(http, '/my-resources'); - } - - /** - * Get a list of resources from xMatters. - * The results can be filtered and paginated using the params object. - * - * @param params Optional parameters to filter and paginate the results - * @returns The HTTP response containing a paginated list of resources - * @throws {XmApiError} If the request fails - */ - getMyResources(params?: GetMyResourcesParams): Promise> { - return this.http.get({ query: params }); - } - - /** - * Get a resource by ID - * - * @param id The ID of the resource to retrieve - * @returns The HTTP response containing the resource - * @throws {XmApiError} If the request fails - */ - getById(id: string): Promise> { - return this.http.get({ path: id }); - } - - /** - * Create a new resource or update an existing one - * - * @param resource The resource data to create or update - * @returns The HTTP response containing the created or updated resource - * @throws {XmApiError} If the request fails - */ - save(resource: Partial): Promise> { - return this.http.post({ body: resource }); - } - - /** - * Delete a resource by ID - * - * @param id The ID of the resource to delete - * @returns The HTTP response - * @throws {XmApiError} If the request fails - */ - delete(id: string): Promise { - return this.http.delete({ path: id }); - } -} -```` - -## Adding to Main API Class - -Don't forget to add your new endpoint to the main `XmApi` class: - -```typescript -// In src/index.ts -import { MyResourcesEndpoint } from './endpoints/my-resources/index.ts'; - -export class XmApi { - /** HTTP handler that manages all API requests */ - private readonly http: RequestHandler; - - /** Access groups-related endpoints */ - public readonly groups: GroupsEndpoint; - - /** Access my-resources-related endpoints */ - public readonly myResources: MyResourcesEndpoint; - - constructor(options: XmApiOptions) { - // ...existing code... - - // Initialize endpoints - this.groups = new GroupsEndpoint(this.http); - this.myResources = new MyResourcesEndpoint(this.http); // Add this line - } -} - -// Also export the types -export * from './endpoints/my-resources/types.ts'; -``` - -## Key Benefits of This Pattern - -1. **Consistent Return Types**: All methods return `Promise>` for predictable - handling -2. **Type Safety**: Full TypeScript support with proper generics -3. **Reusable Components**: Use type composers for common patterns (pagination, search, etc.) -4. **Easy Testing**: MockRequestHandler provides consistent testing patterns -5. **Automatic Path Management**: ResourceClient handles base path automatically -6. **Comprehensive Documentation**: Clear JSDoc comments for all methods -7. **Zero Dependencies**: Uses only Deno standard library and internal utilities - -## Response Type Guidelines - -- **Paginated responses**: Use `PaginatedHttpResponse` - provides semantic meaning for lists -- **Single resources**: Use `HttpResponse` directly - clear and explicit -- **Empty responses**: Use `EmptyHttpResponse` - adds semantic meaning for void operations - -## Response Type Examples - -### For Consumers - -```typescript -// Get groups with full HTTP response access -const response = await xm.groups.getGroups({ limit: 10 }); - -// Access response metadata -console.log('Status:', response.status); -console.log('Headers:', response.headers); - -// Access response body -console.log('Total groups:', response.body.total); -response.body.data.forEach((group) => { - console.log('Group:', group.targetName); -}); -``` - -### For Error Handling - -```typescript -try { - const response = await xm.groups.getGroups(); - // Handle success -} catch (error) { - if (error instanceof XmApiError) { - console.log('Error message:', error.message); - if (error.response) { - console.log('HTTP status:', error.response.status); - console.log('Response body:', error.response.body); - } - } -} -``` - -## Testing Patterns - -The library uses `MockRequestHandler` for consistent testing: - -```typescript -// Create mock response -const mockResponse = createMockResponse({ - body: {/* your response data */}, - status: 200, - headers: { 'content-type': 'application/json' }, -}); - -// Create mock HTTP handler -const mockHttp = new MockRequestHandler(mockResponse); -const endpoint = new MyResourcesEndpoint(mockHttp); - -// Call endpoint method -const response = await endpoint.getMyResources(); - -// Assert on response -assertEquals(response.body, expectedBody); - -// Assert on request that was made -const request = mockHttp.requests[0]; -assertEquals(request.method, 'GET'); -assertEquals(request.path, '/my-resources'); -``` - -This pattern ensures consistency across all endpoints while maintaining flexibility for consumers to -access both response data and HTTP metadata when needed. diff --git a/docs/improvements.md b/docs/improvements.md index 818f618..c2069b0 100644 --- a/docs/improvements.md +++ b/docs/improvements.md @@ -31,14 +31,10 @@ export interface RetryConfig { 2. Update RequestHandler constructor to accept this config: ```typescript -constructor( - client: HttpClient, - logger: Logger, - requestBuilder: RequestBuilder, +constructor({ + ...options, retryConfig?: RetryConfig, - onTokenRefresh?: (accessToken: string, refreshToken: string) => void | Promise, - tokenData?: TokenData, -) +}) ``` 3. Implement custom retry logic in send() method using these options diff --git a/sandbox/.env.example b/sandbox/.env.example new file mode 100644 index 0000000..3f78848 --- /dev/null +++ b/sandbox/.env.example @@ -0,0 +1,4 @@ +HOSTNAME='https://example.xmatters.com' +USERNAME='your-username' +PASSWORD='your-password' +CLIENT_ID='your-client-id' diff --git a/sandbox/README.md b/sandbox/README.md new file mode 100644 index 0000000..649d1bb --- /dev/null +++ b/sandbox/README.md @@ -0,0 +1,25 @@ +# Sandbox + +Edit the `index.ts` file for quick prototyping +and **confirming** the **shapes** of **requests** xmApi expects and the **reponses** it returns. + +## Setup + +1. Copy the environment configuration: + ```sh + cp .env.example .env + ``` + The `.env` file is automatically gitignored to keep your credentials safe. + +2. Edit the `.env` file with your actual xMatters credentials: + - `HOSTNAME`: Your xMatters instance hostname (without `https://`) + - `USERNAME`: Your xMatters username + - `PASSWORD`: Your xMatters password + - `CLIENT_ID`: Your OAuth client ID (if using OAuth) + +## Usage + +Run the example: +```sh +deno run --allow-net --allow-read --allow-env index.ts +``` diff --git a/sandbox/credentials.ts b/sandbox/credentials.ts new file mode 100644 index 0000000..e98d2a9 --- /dev/null +++ b/sandbox/credentials.ts @@ -0,0 +1,33 @@ +import { loadEnvFile } from './loadEnv.ts'; + +await loadEnvFile(); + +const { + HOSTNAME, + USERNAME, + PASSWORD, + CLIENT_ID, +} = Deno.env.toObject(); + +const basicAuth = { + hostname: HOSTNAME, + username: USERNAME, + password: PASSWORD, +}; + +const oauth = { + // Acquire OAuth tokens using basic authentication + // then auto-switch to OAuth for subsequent requests + byUsernamePassword: { + ...basicAuth, + clientId: CLIENT_ID, // Add clientId for OAuth token acquisition + }, + byRefreshToken: { + hostname: HOSTNAME, + clientId: CLIENT_ID, + accessToken: 'TODO', + refreshToken: 'TODO', + }, +}; + +export default { basicAuth, oauth }; diff --git a/sandbox/index.ts b/sandbox/index.ts index 2ab8f39..268efac 100644 --- a/sandbox/index.ts +++ b/sandbox/index.ts @@ -1,15 +1,10 @@ import { XmApi } from '../src/index.ts'; import config from './credentials.ts'; -// Create API client using basic auth -const xm = new XmApi({ - hostname: config.basicAuth.hostname, - username: config.basicAuth.username, - password: config.basicAuth.password, -}); +const xm = new XmApi(config.basicAuth); xm.groups - .getGroups({ + .get({ limit: 10, offset: 0, }) @@ -21,11 +16,9 @@ xm.groups console.log(`Groups Count: ${body.count}`); console.log('-------------------------'); body.data.forEach((group) => { - console.log(`Group ID: ${group.id}`); - console.log(`Group Name: ${group.targetName}`); - console.log(`Group Description: ${group.description}`); - console.log('-------------------------'); - console.log('Raw Response:', JSON.stringify(group, null, 2)); + console.log('Group ID: ', group.id); + console.log('Group Name: ', group.targetName); + console.log('Group Description: ', group.description); console.log('-------------------------'); }); }) diff --git a/sandbox/loadEnv.ts b/sandbox/loadEnv.ts new file mode 100644 index 0000000..205dee0 --- /dev/null +++ b/sandbox/loadEnv.ts @@ -0,0 +1,39 @@ +/** + * Load environment variables from .env file if it exists (for local development) + */ +export async function loadEnvFile(): Promise { + try { + // Look for .env file in the sandbox directory + const envPath = new URL('.env', import.meta.url).pathname; + const envContent = await Deno.readTextFile(envPath); + for (const line of envContent.split('\n')) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#')) { + const [key, ...valueParts] = trimmed.split('='); + if (key && valueParts.length > 0) { + // Remove quotes if present and join value parts + let value = valueParts.join('=').trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + // Only set if not already defined (environment variables take precedence) + if (!Deno.env.get(key)) { + Deno.env.set(key, value); + } + } + } + } + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + console.warn( + 'No .env file found in sandbox directory. Please copy .env.example to .env and configure your credentials.', + ); + } else { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to load environment variables from .env file: ${errorMessage}`); + } + } +} diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index 2b5fa86..fb8831e 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -189,6 +189,7 @@ export class RequestHandler { } try { + this.logger.debug(`DEBUG: Sending request: ${fullRequest.method} ${fullRequest.url}`); const response = await this.client.send(fullRequest); if (response.status >= 400) { diff --git a/src/core/types/endpoint/response.ts b/src/core/types/endpoint/response.ts index eb02e42..5764883 100644 --- a/src/core/types/endpoint/response.ts +++ b/src/core/types/endpoint/response.ts @@ -36,7 +36,7 @@ export interface PaginatedResponse { * ```typescript * // Instead of: Promise> * // Use: Promise> - * getGroups(): Promise> { + * get(): Promise> { * return this.http.get>({ path: '/groups' }); * } * ``` diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index 536a755..37f4209 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -143,11 +143,11 @@ const mockEmptyResponse = { }; Deno.test('GroupsEndpoint', async (t) => { - await t.step('getGroups() - sends correct HTTP request with no params', async () => { + await t.step('get() - sends correct HTTP request with no params', async () => { const { mockHttpClient, endpoint } = createEndpointTestSetup(); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); try { - const response = await endpoint.getGroups(); + const response = await endpoint.get(); // Verify HTTP client was called exactly once expect(sendStub.calls.length).toBe(1); // Verify the request details @@ -165,11 +165,11 @@ Deno.test('GroupsEndpoint', async (t) => { } }); - await t.step('getGroups() - sends correct HTTP request with pagination params', async () => { + await t.step('get() - sends correct HTTP request with pagination params', async () => { const { mockHttpClient, endpoint } = createEndpointTestSetup(); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); try { - const response = await endpoint.getGroups({ limit: 10, offset: 20 }); + const response = await endpoint.get({ limit: 10, offset: 20 }); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); expect(sentRequest.url).toBe( @@ -185,11 +185,11 @@ Deno.test('GroupsEndpoint', async (t) => { } }); - await t.step('getGroups() - sends correct HTTP request with search params', async () => { + await t.step('get() - sends correct HTTP request with search params', async () => { const { mockHttpClient, endpoint } = createEndpointTestSetup(); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); try { - const response = await endpoint.getGroups({ search: 'oncall', limit: 5 }); + const response = await endpoint.get({ search: 'oncall', limit: 5 }); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); expect(sentRequest.url).toBe( @@ -273,7 +273,7 @@ Deno.test('GroupsEndpoint', async (t) => { }); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); try { - const response = await endpoint.getGroups(); + const response = await endpoint.get(); expect(sendStub.calls.length).toBe(1); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); @@ -320,7 +320,7 @@ Deno.test('GroupsEndpoint', async (t) => { try { let thrownError: unknown; try { - await endpoint.getGroups(); + await endpoint.get(); } catch (error) { thrownError = error; } @@ -339,7 +339,7 @@ Deno.test('GroupsEndpoint', async (t) => { }); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); try { - const response = await endpoint.getGroups(); + const response = await endpoint.get(); expect(sendStub.calls.length).toBe(1); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); @@ -361,7 +361,7 @@ Deno.test('GroupsEndpoint', async (t) => { }); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); try { - const response = await endpoint.getGroups(); + const response = await endpoint.get(); expect(sendStub.calls.length).toBe(1); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); @@ -411,7 +411,7 @@ Deno.test('GroupsEndpoint', async (t) => { } }); - await t.step('getGroups() with all possible parameters', async () => { + await t.step('get() with all possible parameters', async () => { const { mockHttpClient, endpoint } = createEndpointTestSetup(); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); const params = { @@ -421,7 +421,7 @@ Deno.test('GroupsEndpoint', async (t) => { // Add other params that might exist in GetGroupsParams }; try { - const response = await endpoint.getGroups(params); + const response = await endpoint.get(params); expect(sendStub.calls.length).toBe(1); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); @@ -463,7 +463,7 @@ Deno.test('GroupsEndpoint', async (t) => { try { // Start the async request but DON'T await it yet // This begins the async chain but allows us to control timing with FakeTime - const requestPromise = endpoint.getGroups(); + const requestPromise = endpoint.get(); // Allow the first request to complete and set up the timer // This advances fake time to let the setTimeout callback fire @@ -530,7 +530,7 @@ Deno.test('GroupsEndpoint', async (t) => { try { // Start the async request but DON'T await it yet // This begins the async chain but allows us to control timing with FakeTime - const requestPromise = endpoint.getGroups(); + const requestPromise = endpoint.get(); // Allow the first request to complete and set up the timer // This advances fake time to let the setTimeout callback fire @@ -613,7 +613,7 @@ Deno.test('GroupsEndpoint', async (t) => { }); const warnStub = stub(mockLogger, 'warn', () => {}); try { - const response = await endpoint.getGroups(); + const response = await endpoint.get(); expect(response.status).toBe(200); expect(sendStub.calls.length).toBe(3); // initial request (401), token refresh, retry request expect(warnStub.calls.length).toBe(1); @@ -662,7 +662,7 @@ Deno.test('GroupsEndpoint', async (t) => { try { let thrownError: unknown; try { - await endpoint.getGroups(); + await endpoint.get(); } catch (error) { thrownError = error; } @@ -690,7 +690,7 @@ Deno.test('GroupsEndpoint', async (t) => { try { let thrownError: unknown; try { - await endpoint.getGroups(); + await endpoint.get(); } catch (error) { thrownError = error; } diff --git a/src/endpoints/groups/index.ts b/src/endpoints/groups/index.ts index cff6e4c..1cfeb16 100644 --- a/src/endpoints/groups/index.ts +++ b/src/endpoints/groups/index.ts @@ -10,28 +10,6 @@ import { GetGroupsParams, GetGroupsResponse, Group } from './types.ts'; /** * Provides access to the groups endpoints of the xMatters API. * Use this class to manage groups, including listing, creating, updating, and deleting groups. - * - * @example - * ```typescript - * const xm = new XmApi({ - * baseUrl: 'https://example.xmatters.com', - * accessToken: 'your-token' - * }); - * - * // Get all groups - * const { body: groups } = await xm.groups.getGroups(); - * - * // Get groups with pagination - * const { body: pagedGroups } = await xm.groups.getGroups({ - * limit: 10, - * offset: 0 - * }); - * - * // Search for groups - * const { body: searchedGroups } = await xm.groups.getGroups({ - * search: 'oncall' - * }); - * ``` */ export class GroupsEndpoint { private readonly http: ResourceClient; @@ -48,7 +26,7 @@ export class GroupsEndpoint { * @returns The HTTP response containing a paginated list of groups * @throws {XmApiError} If the request fails */ - getGroups(params?: GetGroupsParams): Promise> { + get(params?: GetGroupsParams): Promise> { return this.http.get({ query: params }); } diff --git a/src/endpoints/groups/types.ts b/src/endpoints/groups/types.ts index a5b1f2e..499bddb 100644 --- a/src/endpoints/groups/types.ts +++ b/src/endpoints/groups/types.ts @@ -7,43 +7,30 @@ import { WithPagination, WithSearch } from '../../core/types/endpoint/composers. export interface Group { /** Unique identifier for the group */ id: string; - /** The name of the group used for targeting */ targetName: string; - /** Type of recipient */ recipientType: 'GROUP' | 'DEVICE' | 'PERSON'; - /** Whether the group is active or inactive */ status: 'ACTIVE' | 'INACTIVE'; - /** The type of group */ groupType: 'ON_CALL' | 'BROADCAST' | 'DYNAMIC'; - /** ISO timestamp when the group was created */ created: string; - /** Optional description of the group's purpose */ description?: string; - /** List of user IDs that are supervisors of this group */ supervisors?: string[]; - /** Whether the group is managed by an external system */ externallyOwned?: boolean; - /** Whether duplicate members are allowed */ allowDuplicates?: boolean; - /** Whether to use default devices for members */ useDefaultDevices?: boolean; - /** Whether the group is visible to all users */ observedByAll?: boolean; - /** External identifier for the group */ externalKey?: string; - /** Site information if the group belongs to a specific site */ site?: { id: string; @@ -52,19 +39,17 @@ export interface Group { self: string; }; }; - /** HAL links for the group */ links?: { /** URL to this group resource */ self: string; }; - /** ISO timestamp when the group was last modified */ lastModified?: string; } /** - * Specific filter parameters for the getGroups endpoint + * Type for filters that can be applied when retrieving groups. */ export interface GroupFilters extends Record { /** @@ -75,12 +60,13 @@ export interface GroupFilters extends Record { } /** - * Parameters for the getGroups endpoint. + * Type for parameters used in methods that retrieve lists of groups. * Combines common pagination and search with group-specific filters. */ export type GetGroupsParams = WithPagination>; /** - * Response type for the getGroups endpoint. + * Response type for methods that return a list of groups. + * This is a paginated response containing an array of Group objects. */ export type GetGroupsResponse = PaginatedResponse; diff --git a/src/endpoints/oauth/index.ts b/src/endpoints/oauth/index.ts index 680f94d..63b7385 100644 --- a/src/endpoints/oauth/index.ts +++ b/src/endpoints/oauth/index.ts @@ -99,7 +99,7 @@ export class OAuthEndpoint { * }); * * // Now all subsequent API calls use OAuth - * const groups = await xm.groups.getGroups(); + * const groups = await xm.groups.get(); * ``` */ async getTokensByAuthCode(params: TokenByAuthCodeParams): Promise> { diff --git a/src/index.ts b/src/index.ts index 9e2f97b..30dd777 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,16 +42,13 @@ import { OAuthEndpoint } from './endpoints/oauth/index.ts'; export class XmApi { /** HTTP handler that manages all API requests */ private readonly http: RequestHandler; - /** Access groups-related endpoints */ public readonly groups: GroupsEndpoint; - /** Access OAuth-related endpoints for token acquisition */ public readonly oauth: OAuthEndpoint; - constructor(private readonly options: XmApiOptions) { + constructor(options: XmApiOptions) { this.http = new RequestHandler(options); - // Initialize endpoints this.groups = new GroupsEndpoint(this.http); this.oauth = new OAuthEndpoint(this.http); From 0dcb2f2180a2ef5a82fb3cde2a5e0641830d6ee9 Mon Sep 17 00:00:00 2001 From: johan Date: Mon, 9 Jun 2025 23:51:34 -0700 Subject: [PATCH 032/101] commit stragglers --- .gitignore | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index c6a44af..777bc70 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -sandbox/credentials.ts +sandbox/.env dist/ node_modules/ \ No newline at end of file diff --git a/README.md b/README.md index 3815d15..c2b5440 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ console.log('Headers:', response.headers); console.log('Created group:', response.body); // Get groups with pagination: -const groupsResponse = await xmas.groups.getGroups({ limit: 10, offset: 0 }); +const groupsResponse = await xmas.groups.get({ limit: 10, offset: 0 }); console.log('Total groups:', groupsResponse.body.total); groupsResponse.body.data.forEach((group) => { console.log('Group:', group.targetName); From f69eed1a22ab644b1e41f5fed396af580a94e743 Mon Sep 17 00:00:00 2001 From: johan Date: Mon, 9 Jun 2025 23:59:03 -0700 Subject: [PATCH 033/101] Small tweaks --- sandbox/.env.example | 2 ++ sandbox/README.md | 13 +++---------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/sandbox/.env.example b/sandbox/.env.example index 3f78848..be4f863 100644 --- a/sandbox/.env.example +++ b/sandbox/.env.example @@ -1,4 +1,6 @@ +# Your xMatters instance hostname (with `https://`) HOSTNAME='https://example.xmatters.com' USERNAME='your-username' PASSWORD='your-password' +# for OAuth CLIENT_ID='your-client-id' diff --git a/sandbox/README.md b/sandbox/README.md index 649d1bb..2bd9fd7 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -1,25 +1,18 @@ # Sandbox -Edit the `index.ts` file for quick prototyping -and **confirming** the **shapes** of **requests** xmApi expects and the **reponses** it returns. +Use to `confirm` the `shapes` of the **requests** xmApi expects and the **reponses** it returns. ## Setup -1. Copy the environment configuration: +1. Create a gitIgnored `.env` file ```sh cp .env.example .env ``` - The `.env` file is automatically gitignored to keep your credentials safe. -2. Edit the `.env` file with your actual xMatters credentials: - - `HOSTNAME`: Your xMatters instance hostname (without `https://`) - - `USERNAME`: Your xMatters username - - `PASSWORD`: Your xMatters password - - `CLIENT_ID`: Your OAuth client ID (if using OAuth) +2. Edit the `.env` file with your actual xMatters credentials ## Usage -Run the example: ```sh deno run --allow-net --allow-read --allow-env index.ts ``` From 3e3da588b688be9a128fef3ade29329c31575ca6 Mon Sep 17 00:00:00 2001 From: johan Date: Tue, 10 Jun 2025 00:33:36 -0700 Subject: [PATCH 034/101] Refine the sandbox --- deno.json | 3 ++- sandbox/README.md | 4 ++- sandbox/{credentials.ts => config.ts} | 6 ++--- sandbox/index.ts | 8 ++++-- sandbox/loadEnv.ts | 39 --------------------------- 5 files changed, 13 insertions(+), 47 deletions(-) rename sandbox/{credentials.ts => config.ts} (89%) delete mode 100644 sandbox/loadEnv.ts diff --git a/deno.json b/deno.json index 2fc99bc..932d79f 100644 --- a/deno.json +++ b/deno.json @@ -4,7 +4,8 @@ }, "tasks": { "test": "DENO_TLS_CA_STORE=system deno test", - "cache": "DENO_TLS_CA_STORE=system deno cache --reload src/**/*.ts" + "cache": "DENO_TLS_CA_STORE=system deno cache --reload src/**/*.ts", + "sandbox": "DENO_TLS_CA_STORE=system deno run --allow-read --allow-net --env-file=sandbox/.env --allow-env sandbox/index.ts" }, "fmt": { "singleQuote": true, diff --git a/sandbox/README.md b/sandbox/README.md index 2bd9fd7..75d146b 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -13,6 +13,8 @@ Use to `confirm` the `shapes` of the **requests** xmApi expects and the **repons ## Usage +**From the root directory:** + ```sh -deno run --allow-net --allow-read --allow-env index.ts +deno task sandox ``` diff --git a/sandbox/credentials.ts b/sandbox/config.ts similarity index 89% rename from sandbox/credentials.ts rename to sandbox/config.ts index e98d2a9..2ee4f05 100644 --- a/sandbox/credentials.ts +++ b/sandbox/config.ts @@ -1,7 +1,3 @@ -import { loadEnvFile } from './loadEnv.ts'; - -await loadEnvFile(); - const { HOSTNAME, USERNAME, @@ -9,6 +5,8 @@ const { CLIENT_ID, } = Deno.env.toObject(); +// Various configuration options to initiate the SDK with + const basicAuth = { hostname: HOSTNAME, username: USERNAME, diff --git a/sandbox/index.ts b/sandbox/index.ts index 268efac..a12de0a 100644 --- a/sandbox/index.ts +++ b/sandbox/index.ts @@ -1,5 +1,5 @@ import { XmApi } from '../src/index.ts'; -import config from './credentials.ts'; +import config from './config.ts'; const xm = new XmApi(config.basicAuth); @@ -18,7 +18,7 @@ xm.groups body.data.forEach((group) => { console.log('Group ID: ', group.id); console.log('Group Name: ', group.targetName); - console.log('Group Description: ', group.description); + group.description && console.log('Group Description: ', group.description); console.log('-------------------------'); }); }) @@ -29,4 +29,8 @@ xm.groups console.log('Response Headers:', error.response.headers); console.log('Response Body:', error.response.body); } + console.log('\n\n🚨 Troubleshooting checklist:'); + console.log('1. Is the .env file configured correctly?'); + console.log('2. Are the credentials correct?'); + console.log('3. Is the API version compatible?'); }); diff --git a/sandbox/loadEnv.ts b/sandbox/loadEnv.ts deleted file mode 100644 index 205dee0..0000000 --- a/sandbox/loadEnv.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Load environment variables from .env file if it exists (for local development) - */ -export async function loadEnvFile(): Promise { - try { - // Look for .env file in the sandbox directory - const envPath = new URL('.env', import.meta.url).pathname; - const envContent = await Deno.readTextFile(envPath); - for (const line of envContent.split('\n')) { - const trimmed = line.trim(); - if (trimmed && !trimmed.startsWith('#')) { - const [key, ...valueParts] = trimmed.split('='); - if (key && valueParts.length > 0) { - // Remove quotes if present and join value parts - let value = valueParts.join('=').trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - // Only set if not already defined (environment variables take precedence) - if (!Deno.env.get(key)) { - Deno.env.set(key, value); - } - } - } - } - } catch (error) { - if (error instanceof Deno.errors.NotFound) { - console.warn( - 'No .env file found in sandbox directory. Please copy .env.example to .env and configure your credentials.', - ); - } else { - const errorMessage = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to load environment variables from .env file: ${errorMessage}`); - } - } -} From 0143a18afca6cce0de8ca84ceb139fcb031801a7 Mon Sep 17 00:00:00 2001 From: Johan Date: Tue, 10 Jun 2025 21:39:59 -0700 Subject: [PATCH 035/101] Add FYI to list of recommended extensions --- .vscode/extensions.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 09cf720..c31f576 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,6 @@ { "recommendations": [ - "denoland.vscode-deno" + "denoland.vscode-deno", + "johanfive.fyi" ] } From e1fd7cdf490cd5ba8468c953a8b86513124c7acf Mon Sep 17 00:00:00 2001 From: johan Date: Wed, 11 Jun 2025 22:46:00 -0700 Subject: [PATCH 036/101] Replace "options" semantic with "config" semantic for the XmApi constructor arg object Fix failing test that fell through the cracks --- src/core/request-handler.test.ts | 25 +++++++++++--------- src/core/request-handler.ts | 38 +++++++++++++++--------------- src/core/types/internal/config.ts | 6 ++--- src/endpoints/groups/index.test.ts | 20 +++++++++------- src/endpoints/oauth/index.test.ts | 12 +++++----- src/index.ts | 6 ++--- 6 files changed, 56 insertions(+), 51 deletions(-) diff --git a/src/core/request-handler.test.ts b/src/core/request-handler.test.ts index 2c81a8d..00a735d 100644 --- a/src/core/request-handler.test.ts +++ b/src/core/request-handler.test.ts @@ -15,7 +15,7 @@ import { FakeTime } from 'https://deno.land/std@0.224.0/testing/time.ts'; import { stub } from 'https://deno.land/std@0.224.0/testing/mock.ts'; import { RequestHandler } from './request-handler.ts'; import type { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; -import type { Logger, XmApiOptions } from './types/internal/config.ts'; +import type { Logger, XmApiConfig } from './types/internal/config.ts'; import { XmApiError } from './errors.ts'; /** @@ -78,10 +78,10 @@ function createRequestHandlerTestSetup(options: { // Create auth options based on provided parameters const mockHttpClient = new MockHttpClient(responses); - let mockOptions: XmApiOptions; + let mockConfig: XmApiConfig; if (accessToken && refreshToken && clientId) { // OAuth configuration - all three are required - mockOptions = { + mockConfig = { hostname, accessToken, refreshToken, @@ -92,7 +92,7 @@ function createRequestHandlerTestSetup(options: { }; } else { // Basic auth configuration - mockOptions = { + mockConfig = { hostname, username, password, @@ -102,7 +102,7 @@ function createRequestHandlerTestSetup(options: { }; } - const requestHandler = new RequestHandler(mockOptions); + const requestHandler = new RequestHandler(mockConfig); return { mockHttpClient, requestHandler, mockLogger }; } @@ -442,8 +442,9 @@ Deno.test('RequestHandler', async (t) => { expect(mockHttpClient.requests.length).toBe(2); // Verify debug logger was called with correct retry message - expect(debugStub.calls.length).toBe(1); - expect(debugStub.calls[0].args[0]).toBe( + // Should be: initial request log + retry message + retry request log = 3 calls + expect(debugStub.calls.length).toBe(3); + expect(debugStub.calls[1].args[0]).toBe( 'Request failed with status 429, retrying in 1000ms (attempt 1/3)', ); } finally { @@ -606,8 +607,9 @@ Deno.test('RequestHandler', async (t) => { expect(mockHttpClient.requests.length).toBe(2); // Verify debug logger was called with exponential backoff message - expect(debugStub.calls.length).toBe(1); - expect(debugStub.calls[0].args[0]).toBe( + // Should be: initial request log + retry message + retry request log = 3 calls + expect(debugStub.calls.length).toBe(3); + expect(debugStub.calls[1].args[0]).toBe( 'Request failed with status 503, retrying in 1000ms (attempt 1/3)', ); } finally { @@ -652,8 +654,9 @@ Deno.test('RequestHandler', async (t) => { expect(mockHttpClient.requests.length).toBe(2); // Verify debug logger was called with Retry-After header value - expect(debugStub.calls.length).toBe(1); - expect(debugStub.calls[0].args[0]).toBe( + // Should be: initial request log + retry message + retry request log = 3 calls + expect(debugStub.calls.length).toBe(3); + expect(debugStub.calls[1].args[0]).toBe( 'Request failed with status 429, retrying in 5000ms (attempt 1/3)', ); } finally { diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index fb8831e..33a4768 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -5,7 +5,7 @@ import { isOAuthOptions, Logger, TokenRefreshCallback, - XmApiOptions, + XmApiConfig, } from './types/internal/config.ts'; import { DeleteOptions, GetOptions, RequestWithBodyOptions } from './types/internal/methods.ts'; import { XmApiError } from './errors.ts'; @@ -28,19 +28,19 @@ export class RequestHandler { private readonly maxRetries: number; constructor( - private readonly options: XmApiOptions, + private readonly config: XmApiConfig, ) { // Set up internal properties - this.client = options.httpClient ?? new DefaultHttpClient(); - this.logger = options.logger ?? defaultLogger; - this.onTokenRefresh = options.onTokenRefresh; - this.maxRetries = options.maxRetries ?? 3; + this.client = config.httpClient ?? new DefaultHttpClient(); + this.logger = config.logger ?? defaultLogger; + this.onTokenRefresh = config.onTokenRefresh; + this.maxRetries = config.maxRetries ?? 3; // Create initial token state for OAuth if needed - if (isOAuthOptions(options)) { + if (isOAuthOptions(config)) { this.tokenState = { - accessToken: options.accessToken, - refreshToken: options.refreshToken, - clientId: options.clientId, + accessToken: config.accessToken, + refreshToken: config.refreshToken, + clientId: config.clientId, // Set a default expiry 5 minutes from now - we'll get the real value on first refresh expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), scopes: [], @@ -50,9 +50,9 @@ export class RequestHandler { const headers: Record = { 'Content-Type': 'application/json', 'Accept': 'application/json', - ...options.defaultHeaders, + ...config.defaultHeaders, }; - this.requestBuilder = new RequestBuilder(options.hostname, headers); + this.requestBuilder = new RequestBuilder(config.hostname, headers); } /** @@ -153,14 +153,14 @@ export class RequestHandler { * Creates the authorization header value based on the authentication type */ private createAuthHeader(): string | undefined { - if (isOAuthOptions(this.options)) { + if (isOAuthOptions(this.config)) { // For OAuth, get the current access token from token state const currentToken = this.tokenState?.accessToken; return currentToken ? `Bearer ${currentToken}` : undefined; - } else if (isBasicAuthOptions(this.options)) { + } else if (isBasicAuthOptions(this.config)) { // In Deno, we use TextEncoder for proper UTF-8 encoding const encoder = new TextEncoder(); - const authString = `${this.options.username}:${this.options.password}`; + const authString = `${this.config.username}:${this.config.password}`; const auth = btoa(String.fromCharCode(...encoder.encode(authString))); return `Basic ${auth}`; } @@ -275,11 +275,11 @@ export class RequestHandler { * This allows endpoints to access these credentials for OAuth token acquisition. */ getBasicAuthCredentials(): BasicAuthCredentials | undefined { - if (isBasicAuthOptions(this.options)) { + if (isBasicAuthOptions(this.config)) { return { - username: this.options.username, - password: this.options.password, - clientId: this.options.clientId, + username: this.config.username, + password: this.config.password, + clientId: this.config.clientId, }; } return undefined; diff --git a/src/core/types/internal/config.ts b/src/core/types/internal/config.ts index 08e72b0..ff2b60c 100644 --- a/src/core/types/internal/config.ts +++ b/src/core/types/internal/config.ts @@ -69,18 +69,18 @@ export interface XmApiOAuthOptions extends XmApiBaseOptions { /** * Union type of all possible configuration options. */ -export type XmApiOptions = XmApiBasicAuthOptions | XmApiOAuthOptions; +export type XmApiConfig = XmApiBasicAuthOptions | XmApiOAuthOptions; /** * Type guard to determine if options are for OAuth authentication with existing tokens. */ -export function isOAuthOptions(options: XmApiOptions): options is XmApiOAuthOptions { +export function isOAuthOptions(options: XmApiConfig): options is XmApiOAuthOptions { return 'accessToken' in options; } /** * Type guard to determine if options are for basic authentication. */ -export function isBasicAuthOptions(options: XmApiOptions): options is XmApiBasicAuthOptions { +export function isBasicAuthOptions(options: XmApiConfig): options is XmApiBasicAuthOptions { return 'username' in options && 'password' in options; } diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index 37f4209..84e8d89 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -25,7 +25,7 @@ import { FakeTime } from 'https://deno.land/std@0.224.0/testing/time.ts'; import { GroupsEndpoint } from './index.ts'; import { RequestHandler } from '../../core/request-handler.ts'; import type { HttpClient, HttpRequest } from '../../core/types/internal/http.ts'; -import type { Logger, XmApiOptions } from '../../core/types/internal/config.ts'; +import type { Logger, XmApiConfig } from '../../core/types/internal/config.ts'; import type { Group } from './types.ts'; import { XmApiError } from '../../core/errors.ts'; @@ -65,10 +65,10 @@ function createEndpointTestSetup(options: { send: () => Promise.resolve({ status: 200, headers: {}, body: {} }), }; - let mockOptions: XmApiOptions; + let mockConfig: XmApiConfig; if (accessToken && refreshToken && clientId) { // OAuth configuration - all three are required - mockOptions = { + mockConfig = { hostname, accessToken, refreshToken, @@ -80,7 +80,7 @@ function createEndpointTestSetup(options: { }; } else { // Basic auth configuration - mockOptions = { + mockConfig = { hostname, username, password, @@ -91,7 +91,7 @@ function createEndpointTestSetup(options: { }; } - const requestHandler = new RequestHandler(mockOptions); + const requestHandler = new RequestHandler(mockConfig); const endpoint = new GroupsEndpoint(requestHandler); return { mockHttpClient, endpoint, mockLogger }; @@ -492,8 +492,9 @@ Deno.test('GroupsEndpoint', async (t) => { const retryRequest: HttpRequest = sendStub.calls[1].args[0]; expect(retryRequest.method).toBe('GET'); expect(retryRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups'); - expect(debugStub.calls.length).toBe(1); - expect(debugStub.calls[0].args[0]).toBe( + // Should be: initial request log + retry message + retry request log = 3 calls + expect(debugStub.calls.length).toBe(3); + expect(debugStub.calls[1].args[0]).toBe( 'Request failed with status 429, retrying in 2000ms (attempt 1/3)', ); } finally { @@ -553,8 +554,9 @@ Deno.test('GroupsEndpoint', async (t) => { expect(response.body).toEqual(successResponse.body); expect(sendStub.calls.length).toBe(2); // Verify debug logger was called with correct retry message - expect(debugStub.calls.length).toBe(1); - expect(debugStub.calls[0].args[0]).toBe( + // Should be: initial request log + retry message + retry request log = 3 calls + expect(debugStub.calls.length).toBe(3); + expect(debugStub.calls[1].args[0]).toBe( 'Request failed with status 503, retrying in 1000ms (attempt 1/3)', ); } finally { diff --git a/src/endpoints/oauth/index.test.ts b/src/endpoints/oauth/index.test.ts index ef8941f..4244fad 100644 --- a/src/endpoints/oauth/index.test.ts +++ b/src/endpoints/oauth/index.test.ts @@ -10,7 +10,7 @@ import type { HttpClient, HttpRequest, HttpResponse } from '../../core/types/int import type { Logger, TokenRefreshCallback, - XmApiOptions, + XmApiConfig, } from '../../core/types/internal/config.ts'; import type { TokenResponse } from './types.ts'; @@ -74,10 +74,10 @@ function createTestRequestHandler(options: { const mockHttpClient = new MockHttpClient(responses); // Create auth options based on provided parameters - let mockOptions: XmApiOptions; + let mockConfig: XmApiConfig; if (accessToken && refreshToken && clientId) { // OAuth configuration - all three are required - mockOptions = { + mockConfig = { hostname, accessToken, refreshToken, @@ -90,7 +90,7 @@ function createTestRequestHandler(options: { } else { // Create basic auth options even with missing fields so OAuth endpoint can validate them specifically // Use a partial basic auth config to test missing field validation - mockOptions = { + mockConfig = { hostname, username: username!, password: password!, @@ -99,10 +99,10 @@ function createTestRequestHandler(options: { maxRetries: 3, httpClient: mockHttpClient, logger: mockLogger, - } as XmApiOptions; + } as XmApiConfig; } - const requestHandler = new RequestHandler(mockOptions); + const requestHandler = new RequestHandler(mockConfig); return { requestHandler, mockHttpClient, mockLogger }; } diff --git a/src/index.ts b/src/index.ts index 30dd777..15ba293 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { RequestHandler } from './core/request-handler.ts'; -import { XmApiOptions } from './core/types/internal/config.ts'; +import { XmApiConfig } from './core/types/internal/config.ts'; import { GroupsEndpoint } from './endpoints/groups/index.ts'; import { OAuthEndpoint } from './endpoints/oauth/index.ts'; @@ -47,8 +47,8 @@ export class XmApi { /** Access OAuth-related endpoints for token acquisition */ public readonly oauth: OAuthEndpoint; - constructor(options: XmApiOptions) { - this.http = new RequestHandler(options); + constructor(config: XmApiConfig) { + this.http = new RequestHandler(config); // Initialize endpoints this.groups = new GroupsEndpoint(this.http); this.oauth = new OAuthEndpoint(this.http); From 42b8d40372b31e4c4a1cd8266ef86fdf4abf21de Mon Sep 17 00:00:00 2001 From: johan Date: Thu, 12 Jun 2025 00:06:07 -0700 Subject: [PATCH 037/101] Laid out proper plan for proper oauth and credentials DX --- docs/oauth-dx-refactor-plan.md | 380 ++++++++++++++++++++++++++++++ src/core/request-handler.ts | 45 +++- src/endpoints/oauth/index.test.ts | 41 ++-- src/endpoints/oauth/index.ts | 113 +++------ src/endpoints/oauth/types.ts | 17 -- 5 files changed, 469 insertions(+), 127 deletions(-) create mode 100644 docs/oauth-dx-refactor-plan.md diff --git a/docs/oauth-dx-refactor-plan.md b/docs/oauth-dx-refactor-plan.md new file mode 100644 index 0000000..2bbdb0e --- /dev/null +++ b/docs/oauth-dx-refactor-plan.md @@ -0,0 +1,380 @@ +# OAuth DX Refactor Plan + +## Overview + +Implement a new DX approach where configuration determines the authentication flow, and `obtainTokens()` is a single smart method that handles different OAuth flows based on the configuration type. + +## New DX Design + +### Scenario 1: Basic Auth → OAuth (Password Grant) +```typescript +const xm = new XmApi({ + hostname: 'https://company.xmatters.com', + username: 'user@company.com', + password: 'secret' + // No clientId - this is pure basic auth configuration +}); + +// Use basic auth for initial API calls +await xm.groups.get(); + +// Switch to OAuth - auto-discovers clientId +await xm.oauth.obtainTokens(); + +// Or provide explicit clientId to skip discovery +await xm.oauth.obtainTokens({ clientId: 'my-client-id' }); +``` + +### Scenario 2: Auth Code → OAuth +```typescript +const xm = new XmApi({ + hostname: 'https://company.xmatters.com', + authCode: 'received-from-redirect', + clientId: 'web-app-client-id' // Required - no discovery path +}); + +// Must call obtainTokens() before other API calls +await xm.oauth.obtainTokens(); + +// Now can make authenticated API calls +await xm.groups.get(); +``` + +### Scenario 3: Pre-existing OAuth Tokens +```typescript +const xm = new XmApi({ + hostname: 'https://company.xmatters.com', + accessToken: 'existing-token', + refreshToken: 'existing-refresh', + clientId: 'client-id' +}); + +// Already authenticated - can make API calls immediately +await xm.groups.get(); +``` + +## Implementation Tasks + +### 1. Type System Rewrite + +#### 1.1 New Config Types (`src/core/types/internal/config.ts`) +```typescript +// Base configuration +interface XmApiBaseConfig { + hostname: string; + httpClient?: HttpClient; + logger?: Logger; + defaultHeaders?: Record; + maxRetries?: number; + onTokenRefresh?: TokenRefreshCallback; +} + +// Basic auth configuration (can transition to OAuth) +interface BasicAuthConfig extends XmApiBaseConfig { + username: string; + password: string; + // No clientId field - this is pure basic auth +} + +// Auth code configuration (must call obtainTokens before API calls) +interface AuthCodeConfig extends XmApiBaseConfig { + authCode: string; + clientId: string; // Required - no discovery path +} + +// OAuth configuration (ready for API calls) +interface OAuthConfig extends XmApiBaseConfig { + accessToken: string; + refreshToken: string; + clientId: string; +} + +// Union type +type XmApiConfig = BasicAuthConfig | AuthCodeConfig | OAuthConfig; +``` + +#### 1.2 New Type Guards +```typescript +function isBasicAuthConfig(config: XmApiConfig): config is BasicAuthConfig { + return 'username' in config && 'password' in config; +} + +function isAuthCodeConfig(config: XmApiConfig): config is AuthCodeConfig { + return 'authCode' in config; +} + +function isOAuthConfig(config: XmApiConfig): config is OAuthConfig { + return 'accessToken' in config && 'refreshToken' in config; +} +``` + +### 2. RequestHandler Updates (`src/core/request-handler.ts`) + +#### 2.1 Constructor Updates +- Update constructor to handle new config types +- Initialize token state for OAuth configs +- Store auth code data for auth code configs + +#### 2.2 New Helper Methods +```typescript +// Check if this is basic auth configuration +hasBasicAuthConfig(): boolean + +// Check if this is auth code configuration +hasAuthCodeConfig(): boolean + +// Check if this is OAuth configuration +hasOAuthConfig(): boolean + +// Get auth code data for OAuth endpoint +getAuthCodeData(): { clientId: string; authCode: string } | undefined + +// Check if auth code flow is pending (tokens not yet obtained) +isAuthCodePending(): boolean +``` + +#### 2.3 Enhanced send() Method +Add validation in `send()` method to detect when auth code flow hasn't been completed: + +```typescript +async send(request: RequestBuildOptions): Promise> { + // Check if auth code flow is pending + if (this.hasAuthCodeConfig() && this.isAuthCodePending()) { + throw new XmApiError( + 'Auth code configuration detected. Call xm.oauth.obtainTokens() first to exchange code for tokens.' + ); + } + + // ... rest of existing send logic +} +``` + +#### 2.4 Update Existing Methods +- Update `getAuthCredentials()` to work with new BasicAuthConfig +- Update `validateBasicAuthFields()` if still needed +- Update `createAuthHeader()` to handle new config types + +### 3. OAuth Endpoint Rewrite (`src/endpoints/oauth/index.ts`) + +#### 3.1 Smart obtainTokens() Method +```typescript +async obtainTokens(options?: { clientId?: string }): Promise> { + const flow = this.detectFlow(); + + switch (flow.type) { + case 'password': + return this.handlePasswordFlow(options?.clientId ?? flow.clientId); + case 'authCode': + return this.handleAuthCodeFlow(flow.clientId, flow.authCode); + default: + throw new XmApiError('obtainTokens() requires basic auth or auth code configuration'); + } +} +``` + +#### 3.2 Flow Detection Logic +```typescript +private detectFlow(): FlowInfo { + if (this.http.hasBasicAuthConfig()) { + const creds = this.http.getAuthCredentials(); + return { + type: 'password', + username: creds?.username, + password: creds?.password + // No clientId from basic auth config - will be provided or discovered + }; + } + + if (this.http.hasAuthCodeConfig()) { + const authData = this.http.getAuthCodeData(); + return { + type: 'authCode', + clientId: authData!.clientId, + authCode: authData!.authCode + }; + } + + return { type: 'invalid' }; +} +``` + +#### 3.3 Flow Handler Methods +```typescript +private async handlePasswordFlow(clientId?: string): Promise> { + // Get credentials from RequestHandler + const creds = this.http.getAuthCredentials(); + if (!creds) { + throw new XmApiError('Basic auth credentials required for password flow'); + } + + // Use provided clientId or discover it + const finalClientId = clientId ?? await this.discoverClientId(); + + // Make password grant request + const requestBody = new URLSearchParams({ + grant_type: 'password', + client_id: finalClientId, + username: creds.username, + password: creds.password, + }); + + // ... rest of implementation +} + +private async handleAuthCodeFlow(clientId: string, authCode: string): Promise> { + // Make auth code exchange request + const requestBody = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + code: authCode, + // redirect_uri might be needed depending on xMatters API + }); + + // ... rest of implementation +} + +private async discoverClientId(): Promise { + // TODO: Implement clientId discovery via another endpoint + throw new XmApiError('Client ID discovery not yet implemented. Please provide clientId parameter.'); +} +``` + +### 4. Test Updates + +#### 4.1 OAuth Endpoint Tests (`src/endpoints/oauth/index.test.ts`) +- Update test helper `createTestRequestHandler()` to use new config types +- Add tests for auth code flow +- Update existing password flow tests +- Add tests for flow detection logic +- Add tests for error cases (auth code pending, invalid config, etc.) + +#### 4.2 RequestHandler Tests (`src/core/request-handler.test.ts`) +- Update tests to use new config types +- Add tests for new helper methods +- Add tests for auth code pending validation in send() +- Update existing authentication tests + +#### 4.3 Integration Tests +- Test complete flows end-to-end +- Test config validation +- Test error scenarios + +### 5. Supporting Type Updates + +#### 5.1 OAuth Types (`src/core/types/internal/oauth.ts`) +```typescript +// Add auth code related types +interface AuthCodeData { + authCode: string; + clientId: string; +} + +// Flow detection types +type FlowType = 'password' | 'authCode' | 'invalid'; + +interface FlowInfo { + type: FlowType; + clientId?: string; + username?: string; + password?: string; + authCode?: string; +} +``` + +#### 5.2 Update BasicAuthCredentials +```typescript +// Update to match new BasicAuthConfig (no clientId) +export type BasicAuthCredentials = Pick< + BasicAuthConfig, + 'username' | 'password' +>; +``` + +### 6. Documentation Updates + +#### 6.1 Update Method Documentation +- Update JSDoc for `obtainTokens()` method +- Add examples for all three scenarios +- Document the flow detection behavior + +#### 6.2 Update README and Examples +- Update usage examples in README +- Update sandbox examples +- Create migration guide from old API + +### 7. Migration Strategy + +#### 7.1 Backward Compatibility (Optional) +Consider keeping old method names as deprecated aliases: +```typescript +// Deprecated alias +async getTokensByCredentials(): Promise> { + console.warn('getTokensByCredentials() is deprecated. Use obtainTokens() instead.'); + return this.obtainTokens(); +} +``` + +#### 7.2 Breaking Changes +- Constructor parameter types change +- Some config validation behavior changes +- Error messages may change + +### 8. Implementation Order + +1. **Phase 1**: Update type system and type guards +2. **Phase 2**: Update RequestHandler with new helper methods +3. **Phase 3**: Implement auth code pending validation in send() +4. **Phase 4**: Rewrite OAuth endpoint with smart obtainTokens() +5. **Phase 5**: Update all tests +6. **Phase 6**: Add auth code flow implementation +7. **Phase 7**: Implement clientId discovery (future) + +### 9. Key Validation Points + +#### 9.1 Config Validation +- BasicAuthConfig: require username + password only (no clientId) +- AuthCodeConfig: require authCode + clientId +- OAuthConfig: require all three fields + +#### 9.2 Runtime Validation +- Auth code flow: must call obtainTokens() before other API calls +- Password flow: can use basic auth immediately, obtainTokens() switches to OAuth +- OAuth flow: can make API calls immediately + +#### 9.3 Error Scenarios +- Calling API methods with pending auth code +- Invalid config combinations +- Missing required fields +- Network errors during token exchange + +### 10. Future Enhancements + +#### 10.1 ClientId Discovery +Implement endpoint to discover clientId for password flow users. + +#### 10.2 Additional OAuth Flows +- Device code flow +- Client credentials flow +- PKCE support for auth code flow + +#### 10.3 Token Management +- Automatic token refresh +- Token persistence +- Token validation + +## Success Criteria + +- ✅ All three scenarios work as designed +- ✅ Type safety prevents invalid configurations +- ✅ Clear error messages guide users to correct usage +- ✅ Backward compatibility maintained where possible +- ✅ All tests pass +- ✅ Documentation is clear and comprehensive +- ✅ Code is maintainable and extensible + +## Notes + +- The auth code flow detection in `send()` prevents users from forgetting to call `obtainTokens()` +- ClientId discovery can be implemented later without breaking the API +- The flow detection pattern makes it easy to add new OAuth flows in the future +- Type system ensures compile-time validation of configuration validity diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index 33a4768..9f408fa 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -271,17 +271,48 @@ export class RequestHandler { } /** - * Get basic auth credentials from constructor options if available. + * Get authentication credentials from constructor options if available. * This allows endpoints to access these credentials for OAuth token acquisition. + * Returns undefined if basic auth is not configured OR if required fields are missing. */ - getBasicAuthCredentials(): BasicAuthCredentials | undefined { + getAuthCredentials(): BasicAuthCredentials | undefined { if (isBasicAuthOptions(this.config)) { - return { - username: this.config.username, - password: this.config.password, - clientId: this.config.clientId, - }; + // Only return credentials if we have valid username and password + if (this.config.username && this.config.password) { + return { + username: this.config.username, + password: this.config.password, + clientId: this.config.clientId, + }; + } } return undefined; } + + /** + * Check if this is basic auth configuration with specific field validation. + * Used by OAuth endpoint to provide specific error messages. + */ + validateBasicAuthFields(): { hasBasicAuth: boolean; missingField?: 'username' | 'password' } { + if (isBasicAuthOptions(this.config)) { + // Only provide specific field errors if we have partial credentials + // If both username and password are missing, treat as "no credentials" + const hasUsername = !!this.config.username; + const hasPassword = !!this.config.password; + + if (!hasUsername && !hasPassword) { + // Both missing - this is "no credentials" scenario + return { hasBasicAuth: false }; + } + + if (!hasUsername) { + return { hasBasicAuth: true, missingField: 'username' }; + } + if (!hasPassword) { + return { hasBasicAuth: true, missingField: 'password' }; + } + return { hasBasicAuth: true }; + } + return { hasBasicAuth: false }; + } } diff --git a/src/endpoints/oauth/index.test.ts b/src/endpoints/oauth/index.test.ts index 4244fad..0c3eb30 100644 --- a/src/endpoints/oauth/index.test.ts +++ b/src/endpoints/oauth/index.test.ts @@ -94,7 +94,6 @@ function createTestRequestHandler(options: { hostname, username: username!, password: password!, - clientId, onTokenRefresh, maxRetries: 3, httpClient: mockHttpClient, @@ -122,7 +121,7 @@ const mockTokenResponse: HttpResponse = { }, }; -Deno.test('OAuthEndpoint - getTokensByCredentials() - successful token acquisition', async () => { +Deno.test('OAuthEndpoint - obtainTokens() - successful token acquisition', async () => { const { requestHandler, mockHttpClient } = createTestRequestHandler({ username: 'test-user', password: 'test-password', @@ -131,7 +130,7 @@ Deno.test('OAuthEndpoint - getTokensByCredentials() - successful token acquisiti }); const oauthEndpoint = new OAuthEndpoint(requestHandler); - const response = await oauthEndpoint.getTokensByCredentials(); + const response = await oauthEndpoint.obtainTokens({ clientId: 'test-client-id' }); // Verify the request was made correctly expect(mockHttpClient.requests).toHaveLength(1); @@ -162,7 +161,7 @@ Deno.test('OAuthEndpoint - getTokensByCredentials() - successful token acquisiti expect(response.body.scope).toBe('read write'); }); -Deno.test('OAuthEndpoint - getTokensByCredentials() - throws error when no constructor credentials', async () => { +Deno.test('OAuthEndpoint - obtainTokens() - throws error when no constructor credentials', async () => { const { requestHandler, mockHttpClient: _ } = createTestRequestHandler({ // No credentials provided - this will default to basic auth mode but with missing fields responses: [], @@ -170,15 +169,15 @@ Deno.test('OAuthEndpoint - getTokensByCredentials() - throws error when no const const oauthEndpoint = new OAuthEndpoint(requestHandler); - await expect(oauthEndpoint.getTokensByCredentials()).rejects.toThrow( - 'clientId is required for OAuth token acquisition. Provide it in the XmApi constructor.', + await expect(oauthEndpoint.obtainTokens({ clientId: 'test-client-id' })).rejects.toThrow( + 'XmApi must be initialized with basic auth credentials (username, password) to acquire OAuth tokens.', ); // Verify no HTTP request was made expect(_.requests).toHaveLength(0); }); -Deno.test('OAuthEndpoint - getTokensByCredentials() - throws error when clientId is missing', async () => { +Deno.test('OAuthEndpoint - obtainTokens() - throws error when clientId is missing', async () => { const { requestHandler, mockHttpClient: _ } = createTestRequestHandler({ username: 'test-user', password: 'test-password', @@ -188,14 +187,14 @@ Deno.test('OAuthEndpoint - getTokensByCredentials() - throws error when clientId const oauthEndpoint = new OAuthEndpoint(requestHandler); - await expect(oauthEndpoint.getTokensByCredentials()).rejects.toThrow( - 'clientId is required for OAuth token acquisition. Provide it in the XmApi constructor.', + await expect(oauthEndpoint.obtainTokens({ clientId: '' })).rejects.toThrow( + 'clientId is required for OAuth token acquisition. Provide it as a parameter to obtainTokens().', ); expect(_.requests).toHaveLength(0); }); -Deno.test('OAuthEndpoint - getTokensByCredentials() - throws error when username is missing', async () => { +Deno.test('OAuthEndpoint - obtainTokens() - throws error when username is missing', async () => { const { requestHandler, mockHttpClient: _ } = createTestRequestHandler({ password: 'test-password', clientId: 'test-client-id', @@ -205,14 +204,14 @@ Deno.test('OAuthEndpoint - getTokensByCredentials() - throws error when username const oauthEndpoint = new OAuthEndpoint(requestHandler); - await expect(oauthEndpoint.getTokensByCredentials()).rejects.toThrow( + await expect(oauthEndpoint.obtainTokens({ clientId: 'test-client-id' })).rejects.toThrow( 'username is required for OAuth token acquisition. Provide it in the XmApi constructor.', ); expect(_.requests).toHaveLength(0); }); -Deno.test('OAuthEndpoint - getTokensByCredentials() - throws error when password is missing', async () => { +Deno.test('OAuthEndpoint - obtainTokens() - throws error when password is missing', async () => { const { requestHandler, mockHttpClient: _ } = createTestRequestHandler({ username: 'test-user', clientId: 'test-client-id', @@ -222,14 +221,14 @@ Deno.test('OAuthEndpoint - getTokensByCredentials() - throws error when password const oauthEndpoint = new OAuthEndpoint(requestHandler); - await expect(oauthEndpoint.getTokensByCredentials()).rejects.toThrow( + await expect(oauthEndpoint.obtainTokens({ clientId: 'test-client-id' })).rejects.toThrow( 'password is required for OAuth token acquisition. Provide it in the XmApi constructor.', ); expect(_.requests).toHaveLength(0); }); -Deno.test('OAuthEndpoint - getTokensByCredentials() - throws error when API returns non-200 status', async () => { +Deno.test('OAuthEndpoint - obtainTokens() - throws error when API returns non-200 status', async () => { const errorResponse: HttpResponse = { status: 401, headers: { 'content-type': 'application/json' }, @@ -245,14 +244,14 @@ Deno.test('OAuthEndpoint - getTokensByCredentials() - throws error when API retu const oauthEndpoint = new OAuthEndpoint(requestHandler); - await expect(oauthEndpoint.getTokensByCredentials()).rejects.toThrow( + await expect(oauthEndpoint.obtainTokens({ clientId: 'test-client-id' })).rejects.toThrow( 'Request failed with status 401', ); expect(mockHttpClient.requests).toHaveLength(1); }); -Deno.test('OAuthEndpoint - getTokensByCredentials() - calls token refresh callback when provided', async () => { +Deno.test('OAuthEndpoint - obtainTokens() - calls token refresh callback when provided', async () => { let callbackCalled = false; let receivedAccessToken = ''; let receivedRefreshToken = ''; @@ -270,7 +269,7 @@ Deno.test('OAuthEndpoint - getTokensByCredentials() - calls token refresh callba }); const oauthEndpoint = new OAuthEndpoint(requestHandler); - await oauthEndpoint.getTokensByCredentials(); + await oauthEndpoint.obtainTokens({ clientId: 'test-client-id' }); // Verify callback was called with correct tokens expect(callbackCalled).toBe(true); @@ -278,7 +277,7 @@ Deno.test('OAuthEndpoint - getTokensByCredentials() - calls token refresh callba expect(receivedRefreshToken).toBe('test-refresh-token'); }); -Deno.test('OAuthEndpoint - getTokensByCredentials() - does not fail if token refresh callback throws error', async () => { +Deno.test('OAuthEndpoint - obtainTokens() - does not fail if token refresh callback throws error', async () => { const { requestHandler, mockHttpClient: _ } = createTestRequestHandler({ username: 'test-user', password: 'test-password', @@ -292,11 +291,11 @@ Deno.test('OAuthEndpoint - getTokensByCredentials() - does not fail if token ref const oauthEndpoint = new OAuthEndpoint(requestHandler); // Should not throw error even though callback fails - const response = await oauthEndpoint.getTokensByCredentials(); + const response = await oauthEndpoint.obtainTokens({ clientId: 'test-client-id' }); expect(response.body.access_token).toBe('test-access-token'); }); -Deno.test('OAuthEndpoint - getTokensByCredentials() - returns raw API response without field transformation', async () => { +Deno.test('OAuthEndpoint - obtainTokens() - returns raw API response without field transformation', async () => { // Response with actual API field names (snake_case) const apiResponse: HttpResponse = { status: 200, @@ -318,7 +317,7 @@ Deno.test('OAuthEndpoint - getTokensByCredentials() - returns raw API response w }); const oauthEndpoint = new OAuthEndpoint(requestHandler); - const response = await oauthEndpoint.getTokensByCredentials(); + const response = await oauthEndpoint.obtainTokens({ clientId: 'test-client-id' }); // Verify that field names are preserved exactly as returned by API expect(response.body.access_token).toBe('raw-access-token'); diff --git a/src/endpoints/oauth/index.ts b/src/endpoints/oauth/index.ts index 63b7385..4d2e656 100644 --- a/src/endpoints/oauth/index.ts +++ b/src/endpoints/oauth/index.ts @@ -1,6 +1,6 @@ import { RequestHandler } from '../../core/request-handler.ts'; import { XmApiError } from '../../core/errors.ts'; -import { TokenByAuthCodeParams, TokenResponse } from './types.ts'; +import { TokenResponse } from './types.ts'; import type { HttpResponse } from '../../core/types/internal/http.ts'; export class OAuthEndpoint { @@ -13,8 +13,11 @@ export class OAuthEndpoint { * After calling this method, all subsequent API calls will use the acquired OAuth tokens. * This is the "password" grant type in OAuth2 terminology. * - * The username, password, and clientId must be provided in the XmApi constructor. + * The username and password must be provided in the XmApi constructor. + * The clientId must be provided as a parameter to this method. * + * @param options - Options for token acquisition + * @param options.clientId - OAuth client ID for token acquisition * @returns Promise resolving to HTTP response containing token information * * @example @@ -22,36 +25,44 @@ export class OAuthEndpoint { * const xm = new XmApi({ * hostname: 'https://example.xmatters.com', * username: 'your-username', - * password: 'your-password', - * clientId: 'your-client-id' + * password: 'your-password' * }); * - * const { body: tokens } = await xm.oauth.getTokensByCredentials(); + * const { body: tokens } = await xm.oauth.obtainTokens({ + * clientId: 'your-client-id' + * }); * ``` */ - async getTokensByCredentials(): Promise> { + async obtainTokens(options: { clientId: string }): Promise> { + const { clientId } = options; + // Get constructor credentials from RequestHandler - const constructorCredentials = this.http.getBasicAuthCredentials(); + const constructorCredentials = this.http.getAuthCredentials(); if (!constructorCredentials) { + // Check if this is basic auth config with missing fields for more specific error messages + const validation = this.http.validateBasicAuthFields(); + if (validation.hasBasicAuth) { + if (validation.missingField === 'username') { + throw new XmApiError( + 'username is required for OAuth token acquisition. Provide it in the XmApi constructor.', + ); + } + if (validation.missingField === 'password') { + throw new XmApiError( + 'password is required for OAuth token acquisition. Provide it in the XmApi constructor.', + ); + } + } throw new XmApiError( - 'XmApi must be initialized with basic auth credentials (username, password, clientId) to acquire OAuth tokens.', + 'XmApi must be initialized with basic auth credentials (username, password) to acquire OAuth tokens.', ); } - const { clientId, username, password } = constructorCredentials; + const { username, password } = constructorCredentials; + // Validate that we have all required credentials if (!clientId) { throw new XmApiError( - 'clientId is required for OAuth token acquisition. Provide it in the XmApi constructor.', - ); - } - if (!username) { - throw new XmApiError( - 'username is required for OAuth token acquisition. Provide it in the XmApi constructor.', - ); - } - if (!password) { - throw new XmApiError( - 'password is required for OAuth token acquisition. Provide it in the XmApi constructor.', + 'clientId is required for OAuth token acquisition. Provide it as a parameter to obtainTokens().', ); } const requestBody = new URLSearchParams({ @@ -77,66 +88,4 @@ export class OAuthEndpoint { return response; } - - /** - * Obtain OAuth tokens using authorization code, then automatically switch to OAuth mode. - * After calling this method, all subsequent API calls will use the acquired OAuth tokens. - * This is the "authorization_code" grant type in OAuth2 terminology. - * - * @param params - The authorization code, client ID, and related parameters - * @returns Promise resolving to HTTP response containing token information - * - * @example - * ```typescript - * const xm = new XmApi({ - * hostname: 'https://example.xmatters.com', - * }); - * - * const { body: tokens } = await xm.oauth.getTokensByAuthCode({ - * clientId: 'your-client-id', - * code: 'authorization-code-from-callback', - * redirectUri: 'https://your-app.com/callback' - * }); - * - * // Now all subsequent API calls use OAuth - * const groups = await xm.groups.get(); - * ``` - */ - async getTokensByAuthCode(params: TokenByAuthCodeParams): Promise> { - // untested WIP - const { clientId, code, redirectUri, codeVerifier } = params; - - const requestParams = new URLSearchParams({ - grant_type: 'authorization_code', - client_id: clientId, - code, - }); - - if (redirectUri) { - requestParams.append('redirect_uri', redirectUri); - } - - if (codeVerifier) { - requestParams.append('code_verifier', codeVerifier); - } - - const response = await this.http.send({ - method: 'POST', - path: '/oauth2/token', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - }, - body: requestParams.toString(), - skipAuth: true, // Don't add auth headers for token acquisition - }); - - const tokenData = response.body; - - // Handle the newly acquired tokens - await this.http.handleNewTokens(tokenData, clientId); - - // Return the full HTTP response with raw token data (no transformation) - return response; - } } diff --git a/src/endpoints/oauth/types.ts b/src/endpoints/oauth/types.ts index 5278383..4c31ae9 100644 --- a/src/endpoints/oauth/types.ts +++ b/src/endpoints/oauth/types.ts @@ -3,23 +3,6 @@ * These types define the request and response structures for OAuth token operations. */ -/** - * Request parameters for obtaining OAuth tokens using authorization code grant. - * - * All parameters required for the authorization code flow, including those - * generated during the OAuth authorization process. - */ -export interface TokenByAuthCodeParams { - /** The client ID for the OAuth application */ - clientId: string; - /** Authorization code received from the authorization server */ - code: string; - /** Redirect URI that was used in the authorization request */ - redirectUri?: string; - /** Code verifier for PKCE (Proof Key for Code Exchange) */ - codeVerifier?: string; -} - /** * The response returned when successfully obtaining OAuth tokens. * This matches the exact format returned by the xMatters API. From 2288acc3b064f0f3b1915faa54ba9f2ebc6b2b3f Mon Sep 17 00:00:00 2001 From: johan Date: Thu, 12 Jun 2025 00:09:30 -0700 Subject: [PATCH 038/101] remove the backward compatibility requirement because this is all still very much a WIP --- docs/oauth-dx-refactor-plan.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/oauth-dx-refactor-plan.md b/docs/oauth-dx-refactor-plan.md index 2bbdb0e..522e78d 100644 --- a/docs/oauth-dx-refactor-plan.md +++ b/docs/oauth-dx-refactor-plan.md @@ -367,7 +367,6 @@ Implement endpoint to discover clientId for password flow users. - ✅ All three scenarios work as designed - ✅ Type safety prevents invalid configurations - ✅ Clear error messages guide users to correct usage -- ✅ Backward compatibility maintained where possible - ✅ All tests pass - ✅ Documentation is clear and comprehensive - ✅ Code is maintainable and extensible From fac0021fedc3f0a168488793823c2a673c24853f Mon Sep 17 00:00:00 2001 From: johan Date: Thu, 12 Jun 2025 13:06:33 -0700 Subject: [PATCH 039/101] Add a checklist markdown file to support the fact that the agent will inevitably crash throughout the refactoring process --- docs/oauth-refactor-checklist.md | 187 +++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 docs/oauth-refactor-checklist.md diff --git a/docs/oauth-refactor-checklist.md b/docs/oauth-refactor-checklist.md new file mode 100644 index 0000000..fa6d490 --- /dev/null +++ b/docs/oauth-refactor-checklist.md @@ -0,0 +1,187 @@ +# OAuth DX Refactor Implementation Checklist + +## 🎯 Goal +Implement refactor as scoped out in [oauth-dx-refactor-plan.md](./oauth-dx-refactor-plan.md) + +## 📋 Implementation Phases + +### Phase 1: Type System Rewrite ⏳ +- [ ] **1.1** Update `src/core/types/internal/config.ts` + - [ ] Create `XmApiBaseConfig` interface + - [ ] Create `BasicAuthConfig` interface (username + password only) + - [ ] Create `AuthCodeConfig` interface (authCode + clientId) + - [ ] Create `OAuthConfig` interface (accessToken + refreshToken + clientId) + - [ ] Create `XmApiConfig` union type + - [ ] Export all new types +- [ ] **1.2** Add type guards to config.ts + - [ ] `isBasicAuthConfig()` function + - [ ] `isAuthCodeConfig()` function + - [ ] `isOAuthConfig()` function + - [ ] Export type guards +- [ ] **1.3** Update `src/core/types/internal/oauth.ts` + - [ ] Add `AuthCodeData` interface + - [ ] Add `FlowType` type + - [ ] Add `FlowInfo` interface + - [ ] Update `BasicAuthCredentials` type (remove clientId) +- [ ] **1.4** Verify types build without errors + - [ ] Run `deno check src/index.ts` + - [ ] Fix any type errors + +### Phase 2: RequestHandler Updates ⏳ +- [ ] **2.1** Update constructor in `src/core/request-handler.ts` + - [ ] Update config parameter type to `XmApiConfig` + - [ ] Add logic to handle different config types + - [ ] Store auth code data if present + - [ ] Initialize OAuth token state if present +- [ ] **2.2** Add new helper methods + - [ ] `hasBasicAuthConfig(): boolean` + - [ ] `hasAuthCodeConfig(): boolean` + - [ ] `hasOAuthConfig(): boolean` + - [ ] `getAuthCodeData(): { clientId: string; authCode: string } | undefined` + - [ ] `isAuthCodePending(): boolean` + - [ ] Ensure congruency between `getAuthCredentials()` and `getAuthCodeData()` + - [ ] Both return a typed object or `undefined` (never throw) + - [ ] Both are instance methods on `RequestHandler` + - [ ] Both have parallel naming and documentation + - [ ] Both types (`BasicAuthCredentials`, `AuthCodeData`) are defined in `src/core/types/internal/oauth.ts` + - [ ] Both are used in parallel in flow detection and endpoint logic + - [ ] Do not explicitly use `return undefined;` in either method—if a value is not present, simply allow implicit undefined, or if unavoidable, use `return null;` instead. +- [ ] **2.3** Update existing methods + - [ ] Update `getAuthCredentials()` for new BasicAuthConfig + - [ ] Update `createAuthHeader()` for new config types + - [ ] Review and update `validateBasicAuthFields()` if needed +- [ ] **2.4** Verify RequestHandler builds + - [ ] Run `deno check src/core/request-handler.ts` + - [ ] Fix any compilation errors + +### Phase 3: Auth Code Validation in send() ⏳ +- [ ] **3.1** Update `send()` method in RequestHandler + - [ ] Add auth code pending check at start of method + - [ ] Throw clear error if auth code flow not completed + - [ ] Ensure existing logic still works +- [ ] **3.2** Test validation logic + - [ ] Create minimal test to verify error is thrown + - [ ] Ensure existing tests still pass + +### Phase 4: OAuth Endpoint Rewrite ⏳ +- [ ] **4.1** Update `src/endpoints/oauth/index.ts` - Smart obtainTokens() + - [ ] Replace existing methods with `obtainTokens(options?: { clientId?: string })` + - [ ] Add flow detection logic (`detectFlow()` private method) + - [ ] Add password flow handler (`handlePasswordFlow()` private method) + - [ ] Add auth code flow handler (`handleAuthCodeFlow()` private method) + - [ ] Add client ID discovery stub (`discoverClientId()` private method) +- [ ] **4.2** Update OAuth endpoint exports + - [ ] Ensure new method is properly exported + - [ ] Add deprecated aliases if backward compatibility needed +- [ ] **4.3** Update OAuth types in `src/endpoints/oauth/types.ts` + - [ ] Add any new types needed for the methods + - [ ] Update existing types if necessary +- [ ] **4.4** Verify OAuth endpoint builds + - [ ] Run `deno check src/endpoints/oauth/index.ts` + - [ ] Fix any compilation errors + +### Phase 5: Test Updates ⏳ +- [ ] **5.1** Update RequestHandler tests (`src/core/request-handler.test.ts`) + - [ ] Update test helpers to use new config types + - [ ] Add tests for new helper methods + - [ ] Add tests for auth code pending validation in send() + - [ ] Update existing authentication tests + - [ ] Ensure all tests pass: `deno test src/core/request-handler.test.ts` +- [ ] **5.2** Update OAuth endpoint tests (`src/endpoints/oauth/index.test.ts`) + - [ ] Update `createTestRequestHandler()` helper for new config types + - [ ] Add tests for smart `obtainTokens()` method + - [ ] Add tests for flow detection logic + - [ ] Add tests for password flow handler + - [ ] Add tests for auth code flow handler + - [ ] Add tests for error cases (invalid config, auth code pending, etc.) + - [ ] Update existing tests to work with new API + - [ ] Ensure all tests pass: `deno test src/endpoints/oauth/index.test.ts` +- [ ] **5.3** Run full test suite + - [ ] Run `deno test` and ensure all tests pass + - [ ] Fix any failing tests + +### Phase 6: Integration & Validation ⏳ +- [ ] **6.1** Update main exports (`src/index.ts`) + - [ ] Ensure new config types are exported + - [ ] Ensure new OAuth API is exported + - [ ] Verify no breaking changes to public API +- [ ] **6.2** Test scenarios in sandbox + - [ ] Test Scenario 1: Basic Auth → OAuth (Password Grant) + - [ ] Test Scenario 2: Auth Code → OAuth + - [ ] Test Scenario 3: Pre-existing OAuth Tokens + - [ ] Test error cases and edge cases +- [ ] **6.3** Final validation + - [ ] Run full test suite: `deno test` + - [ ] Run type checking: `deno check src/index.ts` + - [ ] Test build process works + - [ ] Verify no runtime errors in sandbox + +### Phase 7: Documentation & Cleanup ⏳ +- [ ] **7.1** Update JSDoc comments + - [ ] Update `obtainTokens()` method documentation + - [ ] Add examples for all three scenarios + - [ ] Document flow detection behavior + - [ ] Update other affected method docs +- [ ] **7.2** Update sandbox examples + - [ ] Update `sandbox/index.ts` with new API examples + - [ ] Test examples work correctly +- [ ] **7.3** Final cleanup + - [ ] Remove any old unused code + - [ ] Clean up console.log or debug statements + - [ ] Ensure code follows project style guidelines + +## 🚨 Critical Checkpoints + +### Before Phase 2 +- [ ] All new types are properly defined and exported +- [ ] Type guards work correctly +- [ ] No TypeScript compilation errors + +### Before Phase 4 +- [ ] RequestHandler properly handles all three config types +- [ ] Auth code validation in send() works correctly +- [ ] All RequestHandler tests pass + +### Before Phase 6 +- [ ] OAuth endpoint smart method works for all flows +- [ ] All OAuth endpoint tests pass +- [ ] No breaking changes to existing working code + +### Before Phase 7 +- [ ] All three scenarios work end-to-end +- [ ] Full test suite passes +- [ ] No TypeScript errors +- [ ] Sandbox examples work + +## 🐛 Common Issues to Watch For +- [ ] Type guard functions must be precise (no false positives) +- [ ] Auth code pending check must not interfere with basic auth or OAuth flows +- [ ] Token state management between flows +- [ ] Error messages must be clear and actionable +- [ ] Backward compatibility with existing working code +- [ ] Test mocks must align with new config types + +## 📝 Implementation Notes +- **Current Status**: Not started +- **Last Updated**: [Date when work begins] +- **Blockers**: None currently identified +- **Next Action**: Begin Phase 1.1 - Update config types + +## 🔄 Resume Instructions +1. Check the last completed checkbox above +2. Read the "Next Action" note +3. Review any "Blockers" or implementation notes +4. Continue from the next unchecked item +5. Update "Last Updated" when making progress + +## ✅ Success Criteria Verification +- [ ] All three scenarios work as designed +- [ ] Type safety prevents invalid configurations +- [ ] Clear error messages guide users to correct usage +- [ ] All tests pass (`deno test`) +- [ ] No TypeScript errors (`deno check src/index.ts`) +- [ ] Documentation is clear and comprehensive +- [ ] Code is maintainable and extensible + +--- +*This checklist tracks the OAuth DX refactor implementation. Update status as work progresses.* From a3b1b1f688189830863f7d5a23f0e97c01f947a8 Mon Sep 17 00:00:00 2001 From: johan Date: Thu, 12 Jun 2025 23:31:36 -0700 Subject: [PATCH 040/101] Committing the new sandbox first --- sandbox/index.ts | 133 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 101 insertions(+), 32 deletions(-) diff --git a/sandbox/index.ts b/sandbox/index.ts index a12de0a..3b765c9 100644 --- a/sandbox/index.ts +++ b/sandbox/index.ts @@ -1,36 +1,105 @@ import { XmApi } from '../src/index.ts'; import config from './config.ts'; -const xm = new XmApi(config.basicAuth); +async function testBasicAuthOnly() { + console.log('\n=== Scenario 1: Basic Auth Only (no clientId) ==='); + const { hostname, username, password } = config.basicAuth; + if (!hostname || !username || !password) { + console.warn('[WARNING] Basic Auth Only: Skipped (missing hostname, username, or password)'); + return; + } + try { + const xm = new XmApi(config.basicAuth); + const response = await xm.groups.get({ limit: 1 }); + console.log('[SUCCESS] Basic Auth Only:', response.status, response.body); + } catch (err) { + printError('[ERROR] Basic Auth Only:', err); + } +} -xm.groups - .get({ - limit: 10, - offset: 0, - }) - .then((response) => { - const { body, status, headers } = response; - console.log('Response Status:', status); - console.log('Response Headers:', headers); - console.log(`Total Groups: ${body.total}`); - console.log(`Groups Count: ${body.count}`); - console.log('-------------------------'); - body.data.forEach((group) => { - console.log('Group ID: ', group.id); - console.log('Group Name: ', group.targetName); - group.description && console.log('Group Description: ', group.description); - console.log('-------------------------'); - }); - }) - .catch((error) => { - console.log('Error fetching groups:', error.message); - if (error.response) { - console.log('Response Status:', error.response.status); - console.log('Response Headers:', error.response.headers); - console.log('Response Body:', error.response.body); - } - console.log('\n\n🚨 Troubleshooting checklist:'); - console.log('1. Is the .env file configured correctly?'); - console.log('2. Are the credentials correct?'); - console.log('3. Is the API version compatible?'); - }); +// Scenario 2: Oauth through Basic Auth with explicit clientId (no discovery) +let lastAccessToken = ''; +let lastRefreshToken = ''; +async function testOauthViaBasicAuthWithExplicitClientId() { + console.log('\n=== Scenario 2: Basic Auth with explicit clientId (no discovery) ==='); + const { hostname, username, password, clientId } = config.oauth.byUsernamePassword; + if (!hostname || !username || !password || !clientId) { + console.warn('[WARNING] Basic Auth with explicit clientId: Skipped (missing required fields)'); + return; + } + try { + const xm = new XmApi(config.oauth.byUsernamePassword); + const tokenResp = await xm.oauth.obtainTokens({ clientId }); + console.log( + '[SUCCESS-1] Basic Auth (explicit clientId): Token Response:', + tokenResp.status, + tokenResp.body, + ); + // Save tokens for scenario 4 + lastAccessToken = tokenResp.body.access_token; + lastRefreshToken = tokenResp.body.refresh_token; + const response = await xm.groups.get({ limit: 1 }); + console.log( + '[SUCCESS-2] Basic Auth (explicit clientId): API Call Response:', + response.status, + response.body, + ); + } catch (err) { + printError('[ERROR] Basic Auth (explicit clientId)', err); + } +} + +async function testPasswordGrantWithDiscovery() { + console.log('\n=== Scenario 3: Password Grant with clientId discovery (should error) ==='); + const { hostname, username, password } = config.basicAuth; + if (!hostname || !username || !password) { + console.warn('[WARNING] Password Grant with discovery: Skipped (missing required fields)'); + return; + } + try { + const xm = new XmApi(config.basicAuth); + await xm.oauth.obtainTokens(); + console.error( + '[ERROR - for now] Password Grant with discovery: Unexpected success (should have errored)', + ); + } catch (err) { + printError('[SUCCESS - for now] Password Grant with discovery', err); + } +} + +async function testPreExistingOAuthTokens() { + console.log('\n=== Scenario 4: Pre-existing OAuth Tokens ==='); + // Use tokens from scenario 2 if available, else fall back to config + const accessToken = lastAccessToken || config.oauth.byRefreshToken.accessToken; + const refreshToken = lastRefreshToken || config.oauth.byRefreshToken.refreshToken; + const { clientId, hostname } = config.oauth.byRefreshToken; + if (!hostname || !accessToken || !refreshToken || !clientId) { + console.warn('[WARNING] Pre-existing OAuth Tokens: Skipped (missing required fields)'); + return; + } + try { + const xm = new XmApi({ hostname, accessToken, refreshToken, clientId }); + const response = await xm.groups.get({ limit: 1 }); + console.log( + '[SUCCESS] Pre-existing OAuth Tokens: API Call Response:', + response.status, + response.body, + ); + } catch (err) { + printError('[ERROR] Pre-existing OAuth Tokens', err); + } +} + +// Run all scenarios sequentially +await testBasicAuthOnly(); +await testOauthViaBasicAuthWithExplicitClientId(); +await testPasswordGrantWithDiscovery(); +await testPreExistingOAuthTokens(); + +function printError(context: string, err: unknown) { + if (err instanceof Error) { + console.error(`${context}: Error:`, err.message); + } else { + console.error(`${context}: Error:`, err); + } +} From d82cdfe19c13648d25f286047526c70fa2345db9 Mon Sep 17 00:00:00 2001 From: johan Date: Sat, 14 Jun 2025 12:13:49 -0700 Subject: [PATCH 041/101] deno fmt --- docs/oauth-dx-refactor-plan.md | 38 ++++++++++++++++++++++++++++---- docs/oauth-refactor-checklist.md | 35 ++++++++++++++++++++++------- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/docs/oauth-dx-refactor-plan.md b/docs/oauth-dx-refactor-plan.md index 522e78d..9cdb55b 100644 --- a/docs/oauth-dx-refactor-plan.md +++ b/docs/oauth-dx-refactor-plan.md @@ -2,16 +2,19 @@ ## Overview -Implement a new DX approach where configuration determines the authentication flow, and `obtainTokens()` is a single smart method that handles different OAuth flows based on the configuration type. +Implement a new DX approach where configuration determines the authentication flow, and +`obtainTokens()` is a single smart method that handles different OAuth flows based on the +configuration type. ## New DX Design ### Scenario 1: Basic Auth → OAuth (Password Grant) + ```typescript const xm = new XmApi({ hostname: 'https://company.xmatters.com', username: 'user@company.com', - password: 'secret' + password: 'secret', // No clientId - this is pure basic auth configuration }); @@ -26,11 +29,12 @@ await xm.oauth.obtainTokens({ clientId: 'my-client-id' }); ``` ### Scenario 2: Auth Code → OAuth + ```typescript const xm = new XmApi({ hostname: 'https://company.xmatters.com', authCode: 'received-from-redirect', - clientId: 'web-app-client-id' // Required - no discovery path + clientId: 'web-app-client-id', // Required - no discovery path }); // Must call obtainTokens() before other API calls @@ -41,12 +45,13 @@ await xm.groups.get(); ``` ### Scenario 3: Pre-existing OAuth Tokens + ```typescript const xm = new XmApi({ hostname: 'https://company.xmatters.com', accessToken: 'existing-token', refreshToken: 'existing-refresh', - clientId: 'client-id' + clientId: 'client-id', }); // Already authenticated - can make API calls immediately @@ -58,6 +63,7 @@ await xm.groups.get(); ### 1. Type System Rewrite #### 1.1 New Config Types (`src/core/types/internal/config.ts`) + ```typescript // Base configuration interface XmApiBaseConfig { @@ -94,6 +100,7 @@ type XmApiConfig = BasicAuthConfig | AuthCodeConfig | OAuthConfig; ``` #### 1.2 New Type Guards + ```typescript function isBasicAuthConfig(config: XmApiConfig): config is BasicAuthConfig { return 'username' in config && 'password' in config; @@ -111,11 +118,13 @@ function isOAuthConfig(config: XmApiConfig): config is OAuthConfig { ### 2. RequestHandler Updates (`src/core/request-handler.ts`) #### 2.1 Constructor Updates + - Update constructor to handle new config types - Initialize token state for OAuth configs - Store auth code data for auth code configs #### 2.2 New Helper Methods + ```typescript // Check if this is basic auth configuration hasBasicAuthConfig(): boolean @@ -134,6 +143,7 @@ isAuthCodePending(): boolean ``` #### 2.3 Enhanced send() Method + Add validation in `send()` method to detect when auth code flow hasn't been completed: ```typescript @@ -150,6 +160,7 @@ async send(request: RequestBuildOptions): Promise> { ``` #### 2.4 Update Existing Methods + - Update `getAuthCredentials()` to work with new BasicAuthConfig - Update `validateBasicAuthFields()` if still needed - Update `createAuthHeader()` to handle new config types @@ -157,6 +168,7 @@ async send(request: RequestBuildOptions): Promise> { ### 3. OAuth Endpoint Rewrite (`src/endpoints/oauth/index.ts`) #### 3.1 Smart obtainTokens() Method + ```typescript async obtainTokens(options?: { clientId?: string }): Promise> { const flow = this.detectFlow(); @@ -173,6 +185,7 @@ async obtainTokens(options?: { clientId?: string }): Promise> { // Get credentials from RequestHandler @@ -242,6 +256,7 @@ private async discoverClientId(): Promise { ### 4. Test Updates #### 4.1 OAuth Endpoint Tests (`src/endpoints/oauth/index.test.ts`) + - Update test helper `createTestRequestHandler()` to use new config types - Add tests for auth code flow - Update existing password flow tests @@ -249,12 +264,14 @@ private async discoverClientId(): Promise { - Add tests for error cases (auth code pending, invalid config, etc.) #### 4.2 RequestHandler Tests (`src/core/request-handler.test.ts`) + - Update tests to use new config types - Add tests for new helper methods - Add tests for auth code pending validation in send() - Update existing authentication tests #### 4.3 Integration Tests + - Test complete flows end-to-end - Test config validation - Test error scenarios @@ -262,6 +279,7 @@ private async discoverClientId(): Promise { ### 5. Supporting Type Updates #### 5.1 OAuth Types (`src/core/types/internal/oauth.ts`) + ```typescript // Add auth code related types interface AuthCodeData { @@ -282,6 +300,7 @@ interface FlowInfo { ``` #### 5.2 Update BasicAuthCredentials + ```typescript // Update to match new BasicAuthConfig (no clientId) export type BasicAuthCredentials = Pick< @@ -293,11 +312,13 @@ export type BasicAuthCredentials = Pick< ### 6. Documentation Updates #### 6.1 Update Method Documentation + - Update JSDoc for `obtainTokens()` method - Add examples for all three scenarios - Document the flow detection behavior #### 6.2 Update README and Examples + - Update usage examples in README - Update sandbox examples - Create migration guide from old API @@ -305,7 +326,9 @@ export type BasicAuthCredentials = Pick< ### 7. Migration Strategy #### 7.1 Backward Compatibility (Optional) + Consider keeping old method names as deprecated aliases: + ```typescript // Deprecated alias async getTokensByCredentials(): Promise> { @@ -315,6 +338,7 @@ async getTokensByCredentials(): Promise> { ``` #### 7.2 Breaking Changes + - Constructor parameter types change - Some config validation behavior changes - Error messages may change @@ -332,16 +356,19 @@ async getTokensByCredentials(): Promise> { ### 9. Key Validation Points #### 9.1 Config Validation + - BasicAuthConfig: require username + password only (no clientId) - AuthCodeConfig: require authCode + clientId - OAuthConfig: require all three fields #### 9.2 Runtime Validation + - Auth code flow: must call obtainTokens() before other API calls - Password flow: can use basic auth immediately, obtainTokens() switches to OAuth - OAuth flow: can make API calls immediately #### 9.3 Error Scenarios + - Calling API methods with pending auth code - Invalid config combinations - Missing required fields @@ -350,14 +377,17 @@ async getTokensByCredentials(): Promise> { ### 10. Future Enhancements #### 10.1 ClientId Discovery + Implement endpoint to discover clientId for password flow users. #### 10.2 Additional OAuth Flows + - Device code flow - Client credentials flow - PKCE support for auth code flow #### 10.3 Token Management + - Automatic token refresh - Token persistence - Token validation diff --git a/docs/oauth-refactor-checklist.md b/docs/oauth-refactor-checklist.md index fa6d490..6290a60 100644 --- a/docs/oauth-refactor-checklist.md +++ b/docs/oauth-refactor-checklist.md @@ -1,11 +1,13 @@ # OAuth DX Refactor Implementation Checklist ## 🎯 Goal + Implement refactor as scoped out in [oauth-dx-refactor-plan.md](./oauth-dx-refactor-plan.md) ## 📋 Implementation Phases ### Phase 1: Type System Rewrite ⏳ + - [ ] **1.1** Update `src/core/types/internal/config.ts` - [ ] Create `XmApiBaseConfig` interface - [ ] Create `BasicAuthConfig` interface (username + password only) @@ -15,7 +17,7 @@ Implement refactor as scoped out in [oauth-dx-refactor-plan.md](./oauth-dx-refac - [ ] Export all new types - [ ] **1.2** Add type guards to config.ts - [ ] `isBasicAuthConfig()` function - - [ ] `isAuthCodeConfig()` function + - [ ] `isAuthCodeConfig()` function - [ ] `isOAuthConfig()` function - [ ] Export type guards - [ ] **1.3** Update `src/core/types/internal/oauth.ts` @@ -28,6 +30,7 @@ Implement refactor as scoped out in [oauth-dx-refactor-plan.md](./oauth-dx-refac - [ ] Fix any type errors ### Phase 2: RequestHandler Updates ⏳ + - [ ] **2.1** Update constructor in `src/core/request-handler.ts` - [ ] Update config parameter type to `XmApiConfig` - [ ] Add logic to handle different config types @@ -43,9 +46,11 @@ Implement refactor as scoped out in [oauth-dx-refactor-plan.md](./oauth-dx-refac - [ ] Both return a typed object or `undefined` (never throw) - [ ] Both are instance methods on `RequestHandler` - [ ] Both have parallel naming and documentation - - [ ] Both types (`BasicAuthCredentials`, `AuthCodeData`) are defined in `src/core/types/internal/oauth.ts` + - [ ] Both types (`BasicAuthCredentials`, `AuthCodeData`) are defined in + `src/core/types/internal/oauth.ts` - [ ] Both are used in parallel in flow detection and endpoint logic - - [ ] Do not explicitly use `return undefined;` in either method—if a value is not present, simply allow implicit undefined, or if unavoidable, use `return null;` instead. + - [ ] Do not explicitly use `return undefined;` in either method—if a value is not present, + simply allow implicit undefined, or if unavoidable, use `return null;` instead. - [ ] **2.3** Update existing methods - [ ] Update `getAuthCredentials()` for new BasicAuthConfig - [ ] Update `createAuthHeader()` for new config types @@ -55,6 +60,7 @@ Implement refactor as scoped out in [oauth-dx-refactor-plan.md](./oauth-dx-refac - [ ] Fix any compilation errors ### Phase 3: Auth Code Validation in send() ⏳ + - [ ] **3.1** Update `send()` method in RequestHandler - [ ] Add auth code pending check at start of method - [ ] Throw clear error if auth code flow not completed @@ -64,6 +70,7 @@ Implement refactor as scoped out in [oauth-dx-refactor-plan.md](./oauth-dx-refac - [ ] Ensure existing tests still pass ### Phase 4: OAuth Endpoint Rewrite ⏳ + - [ ] **4.1** Update `src/endpoints/oauth/index.ts` - Smart obtainTokens() - [ ] Replace existing methods with `obtainTokens(options?: { clientId?: string })` - [ ] Add flow detection logic (`detectFlow()` private method) @@ -81,6 +88,7 @@ Implement refactor as scoped out in [oauth-dx-refactor-plan.md](./oauth-dx-refac - [ ] Fix any compilation errors ### Phase 5: Test Updates ⏳ + - [ ] **5.1** Update RequestHandler tests (`src/core/request-handler.test.ts`) - [ ] Update test helpers to use new config types - [ ] Add tests for new helper methods @@ -92,7 +100,7 @@ Implement refactor as scoped out in [oauth-dx-refactor-plan.md](./oauth-dx-refac - [ ] Add tests for smart `obtainTokens()` method - [ ] Add tests for flow detection logic - [ ] Add tests for password flow handler - - [ ] Add tests for auth code flow handler + - [ ] Add tests for auth code flow handler - [ ] Add tests for error cases (invalid config, auth code pending, etc.) - [ ] Update existing tests to work with new API - [ ] Ensure all tests pass: `deno test src/endpoints/oauth/index.test.ts` @@ -101,13 +109,14 @@ Implement refactor as scoped out in [oauth-dx-refactor-plan.md](./oauth-dx-refac - [ ] Fix any failing tests ### Phase 6: Integration & Validation ⏳ + - [ ] **6.1** Update main exports (`src/index.ts`) - [ ] Ensure new config types are exported - [ ] Ensure new OAuth API is exported - [ ] Verify no breaking changes to public API - [ ] **6.2** Test scenarios in sandbox - [ ] Test Scenario 1: Basic Auth → OAuth (Password Grant) - - [ ] Test Scenario 2: Auth Code → OAuth + - [ ] Test Scenario 2: Auth Code → OAuth - [ ] Test Scenario 3: Pre-existing OAuth Tokens - [ ] Test error cases and edge cases - [ ] **6.3** Final validation @@ -117,6 +126,7 @@ Implement refactor as scoped out in [oauth-dx-refactor-plan.md](./oauth-dx-refac - [ ] Verify no runtime errors in sandbox ### Phase 7: Documentation & Cleanup ⏳ + - [ ] **7.1** Update JSDoc comments - [ ] Update `obtainTokens()` method documentation - [ ] Add examples for all three scenarios @@ -133,27 +143,32 @@ Implement refactor as scoped out in [oauth-dx-refactor-plan.md](./oauth-dx-refac ## 🚨 Critical Checkpoints ### Before Phase 2 + - [ ] All new types are properly defined and exported - [ ] Type guards work correctly - [ ] No TypeScript compilation errors -### Before Phase 4 +### Before Phase 4 + - [ ] RequestHandler properly handles all three config types - [ ] Auth code validation in send() works correctly - [ ] All RequestHandler tests pass ### Before Phase 6 + - [ ] OAuth endpoint smart method works for all flows - [ ] All OAuth endpoint tests pass - [ ] No breaking changes to existing working code ### Before Phase 7 + - [ ] All three scenarios work end-to-end - [ ] Full test suite passes - [ ] No TypeScript errors - [ ] Sandbox examples work ## 🐛 Common Issues to Watch For + - [ ] Type guard functions must be precise (no false positives) - [ ] Auth code pending check must not interfere with basic auth or OAuth flows - [ ] Token state management between flows @@ -162,12 +177,14 @@ Implement refactor as scoped out in [oauth-dx-refactor-plan.md](./oauth-dx-refac - [ ] Test mocks must align with new config types ## 📝 Implementation Notes + - **Current Status**: Not started - **Last Updated**: [Date when work begins] - **Blockers**: None currently identified - **Next Action**: Begin Phase 1.1 - Update config types ## 🔄 Resume Instructions + 1. Check the last completed checkbox above 2. Read the "Next Action" note 3. Review any "Blockers" or implementation notes @@ -175,8 +192,9 @@ Implement refactor as scoped out in [oauth-dx-refactor-plan.md](./oauth-dx-refac 5. Update "Last Updated" when making progress ## ✅ Success Criteria Verification + - [ ] All three scenarios work as designed -- [ ] Type safety prevents invalid configurations +- [ ] Type safety prevents invalid configurations - [ ] Clear error messages guide users to correct usage - [ ] All tests pass (`deno test`) - [ ] No TypeScript errors (`deno check src/index.ts`) @@ -184,4 +202,5 @@ Implement refactor as scoped out in [oauth-dx-refactor-plan.md](./oauth-dx-refac - [ ] Code is maintainable and extensible --- -*This checklist tracks the OAuth DX refactor implementation. Update status as work progresses.* + +_This checklist tracks the OAuth DX refactor implementation. Update status as work progresses._ From 5702ae41d5bbef1858aa698e9de5dd8bbe3093fd Mon Sep 17 00:00:00 2001 From: johan Date: Sat, 14 Jun 2025 16:16:47 -0700 Subject: [PATCH 042/101] Rewrite oauth and finally got around making RequestHandler great again --- src/core/request-handler.ts | 206 +++++++--------- src/core/types/internal/auth-state.ts | 19 ++ src/core/types/internal/config.ts | 52 ++-- src/core/types/internal/methods.ts | 2 + src/core/types/internal/oauth.ts | 3 +- src/core/utils/config-validation.ts | 45 ++++ src/core/utils/index.ts | 6 + src/endpoints/oauth/index.test.ts | 331 -------------------------- src/endpoints/oauth/index.ts | 168 +++++++------ src/endpoints/oauth/types.ts | 8 +- src/index.ts | 7 +- 11 files changed, 293 insertions(+), 554 deletions(-) create mode 100644 src/core/types/internal/auth-state.ts create mode 100644 src/core/utils/config-validation.ts create mode 100644 src/core/utils/index.ts diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index 9f408fa..d18df05 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -1,15 +1,16 @@ import { HttpClient, HttpResponse } from './types/internal/http.ts'; import { - BasicAuthCredentials, - isBasicAuthOptions, - isOAuthOptions, + isAuthCodeConfig, + isBasicAuthConfig, + isOAuthConfig, Logger, TokenRefreshCallback, XmApiConfig, } from './types/internal/config.ts'; +import type { MutableAuthState } from './types/internal/auth-state.ts'; import { DeleteOptions, GetOptions, RequestWithBodyOptions } from './types/internal/methods.ts'; import { XmApiError } from './errors.ts'; -import { OAuth2TokenResponse, TokenState } from './types/internal/oauth.ts'; +import { OAuth2TokenResponse } from './types/internal/oauth.ts'; import { RequestBuilder, RequestBuildOptions } from './request-builder.ts'; import { DefaultHttpClient, defaultLogger } from './defaults/index.ts'; @@ -18,82 +19,68 @@ export class RequestHandler { private readonly client: HttpClient; /** Logger for debug output */ private readonly logger: Logger; - /** Current token state if using OAuth */ - private tokenState?: TokenState; /** Request builder for creating HTTP requests before sending with the client */ private readonly requestBuilder: RequestBuilder; /** Optional callback for token refresh events */ private readonly onTokenRefresh?: TokenRefreshCallback; /** Maximum number of retry attempts for failed requests */ private readonly maxRetries: number; + /** Mutable authentication state - the only property that changes during OAuth transitions */ + private mutableAuthState: MutableAuthState; constructor( - private readonly config: XmApiConfig, + initialConfig: XmApiConfig, ) { - // Set up internal properties - this.client = config.httpClient ?? new DefaultHttpClient(); - this.logger = config.logger ?? defaultLogger; - this.onTokenRefresh = config.onTokenRefresh; - this.maxRetries = config.maxRetries ?? 3; - // Create initial token state for OAuth if needed - if (isOAuthOptions(config)) { - this.tokenState = { - accessToken: config.accessToken, - refreshToken: config.refreshToken, - clientId: config.clientId, - // Set a default expiry 5 minutes from now - we'll get the real value on first refresh - expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), - scopes: [], + // Extract and cache immutable properties + this.client = initialConfig.httpClient ?? new DefaultHttpClient(); + this.logger = initialConfig.logger ?? defaultLogger; + this.onTokenRefresh = initialConfig.onTokenRefresh; + this.maxRetries = initialConfig.maxRetries ?? 3; + // Initialize mutable auth state based on config type + if (isBasicAuthConfig(initialConfig)) { + this.mutableAuthState = { + type: 'basic', + username: initialConfig.username, + password: initialConfig.password, }; + } else if (isOAuthConfig(initialConfig)) { + this.mutableAuthState = { + type: 'oauth', + accessToken: initialConfig.accessToken, + refreshToken: initialConfig.refreshToken, + clientId: initialConfig.clientId, + expiresAt: initialConfig.expiresAt, + }; + } else if (isAuthCodeConfig(initialConfig)) { + this.mutableAuthState = { + type: 'authCode', + authorizationCode: initialConfig.authorizationCode, + clientId: initialConfig.clientId, + clientSecret: initialConfig.clientSecret, + }; + } else { + throw new XmApiError('Invalid configuration type'); } - // Create request builder + // Create request builder with immutable properties const headers: Record = { 'Content-Type': 'application/json', 'Accept': 'application/json', - ...config.defaultHeaders, + ...initialConfig.defaultHeaders, }; - this.requestBuilder = new RequestBuilder(config.hostname, headers); + this.requestBuilder = new RequestBuilder(initialConfig.hostname, headers); } /** - * Handle newly acquired or refreshed OAuth tokens. - * - * This method processes token responses from any source and updates the internal state: - * - OAuth endpoint responses (password grant, authorization code grant) - * - Automatic token refresh during request retry - * - * The method will: - * 1. Update the internal token state with new token data - * 2. Calculate and set the token expiration time - * 3. Execute the onTokenRefresh callback if provided (with error handling) - * - * @param tokenResponse - The token response object from the xMatters API - * @param clientId - Optional client ID for OAuth2 operations (preserved from current state if not provided) + * Execute the onTokenRefresh callback if provided (with error handling) */ - async handleNewTokens( - tokenResponse: OAuth2TokenResponse, - clientId?: string, + private async executeTokenRefreshCallback( + accessToken: string, + refreshToken: string, ): Promise { - // Use provided clientId or fall back to current state's clientId - const finalClientId = clientId ?? this.tokenState?.clientId; - if (!finalClientId) { - throw new XmApiError('Client ID is required for token handling'); - } - - // Update token state - this.tokenState = { - accessToken: tokenResponse.access_token, - refreshToken: tokenResponse.refresh_token, - clientId: finalClientId, - expiresAt: new Date(Date.now() + (tokenResponse.expires_in * 1000)).toISOString(), - scopes: tokenResponse.scope?.split(' ') ?? [], - }; - // Execute callback if provided if (this.onTokenRefresh) { try { - await this.onTokenRefresh(tokenResponse.access_token, tokenResponse.refresh_token); + await this.onTokenRefresh(accessToken, refreshToken); } catch (error) { - // Use proper logger instead of console this.logger.warn( 'Error in onTokenRefresh callback, but continuing with refreshed token', error, @@ -103,24 +90,24 @@ export class RequestHandler { } private isTokenExpired(): boolean { - if (!this.tokenState) return false; - const expiresAt = new Date(this.tokenState.expiresAt); + if (this.mutableAuthState.type !== 'oauth') return false; + // If there's no expiration info, assume it's valid + if (!this.mutableAuthState.expiresAt) return false; + const expiresAt = new Date(this.mutableAuthState.expiresAt); // Consider token expired if it expires in less than 30 seconds return expiresAt.getTime() - Date.now() <= 30 * 1000; } private async refreshToken(): Promise { try { - if (!this.tokenState) { - throw new XmApiError('No token state available for token refresh'); + if (this.mutableAuthState.type !== 'oauth') { + throw new XmApiError('No OAuth configuration available for token refresh'); } - const params = new URLSearchParams({ grant_type: 'refresh_token', - refresh_token: this.tokenState.refreshToken, - client_id: this.tokenState.clientId, + refresh_token: this.mutableAuthState.refreshToken, + client_id: this.mutableAuthState.clientId, }); - const refreshRequest = this.requestBuilder.build({ method: 'POST', path: '/oauth2/token', @@ -130,14 +117,12 @@ export class RequestHandler { }, body: params.toString(), }); - const response = await this.client.send(refreshRequest); - if (response.status < 200 || response.status >= 300) { throw new XmApiError('Failed to refresh token', response); } - - await this.handleNewTokens(response.body as OAuth2TokenResponse, this.tokenState?.clientId); + const tokenResponse = response.body as OAuth2TokenResponse; + await this.handleNewOAuthTokens(tokenResponse, this.mutableAuthState.clientId); } catch (error) { this.logger.error('Failed to refresh token:', error); throw error; @@ -153,14 +138,12 @@ export class RequestHandler { * Creates the authorization header value based on the authentication type */ private createAuthHeader(): string | undefined { - if (isOAuthOptions(this.config)) { - // For OAuth, get the current access token from token state - const currentToken = this.tokenState?.accessToken; - return currentToken ? `Bearer ${currentToken}` : undefined; - } else if (isBasicAuthOptions(this.config)) { + if (this.mutableAuthState.type === 'oauth') { + return `Bearer ${this.mutableAuthState.accessToken}`; + } else if (this.mutableAuthState.type === 'basic') { // In Deno, we use TextEncoder for proper UTF-8 encoding const encoder = new TextEncoder(); - const authString = `${this.config.username}:${this.config.password}`; + const authString = `${this.mutableAuthState.username}:${this.mutableAuthState.password}`; const auth = btoa(String.fromCharCode(...encoder.encode(authString))); return `Basic ${auth}`; } @@ -171,12 +154,10 @@ export class RequestHandler { request: RequestBuildOptions, ): Promise> { // Check if token refresh is needed before making the request - if (this.tokenState && this.isTokenExpired()) { + if (this.mutableAuthState.type === 'oauth' && this.isTokenExpired()) { await this.refreshToken(); } - const fullRequest = this.requestBuilder.build(request); - // Add authorization header unless explicitly skipped if (!request.skipAuth) { const authHeader = this.createAuthHeader(); @@ -187,18 +168,15 @@ export class RequestHandler { }; } } - try { this.logger.debug(`DEBUG: Sending request: ${fullRequest.method} ${fullRequest.url}`); const response = await this.client.send(fullRequest); - if (response.status >= 400) { const currentAttempt = fullRequest.retryAttempt ?? 0; - // Handle OAuth token expiry/refresh first if ( response.status === 401 && - this.tokenState?.refreshToken && + this.mutableAuthState.type === 'oauth' && currentAttempt === 0 ) { await this.refreshToken(); @@ -208,7 +186,6 @@ export class RequestHandler { retryAttempt: 1, }); } - // For rate limits (429) or server errors (5xx), retry with exponential backoff if ( (response.status === 429 || response.status >= 500) && @@ -216,7 +193,6 @@ export class RequestHandler { ) { // Calculate delay based on retry attempt const delay = this.exponentialBackoff(currentAttempt); - // Respect Retry-After header for rate limits if present let finalDelay = delay; if (response.status === 429 && response.headers['retry-after']) { @@ -225,13 +201,11 @@ export class RequestHandler { finalDelay = retryAfter * 1000; } } - this.logger.debug( `Request failed with status ${response.status}, retrying in ${finalDelay}ms (attempt ${ currentAttempt + 1 }/${this.maxRetries})`, ); - await new Promise((resolve) => setTimeout(resolve, finalDelay)); return this.send({ ...request, @@ -240,7 +214,6 @@ export class RequestHandler { } throw new XmApiError('', response); } - return response as HttpResponse; } catch (error) { if (error instanceof XmApiError) { @@ -271,48 +244,31 @@ export class RequestHandler { } /** - * Get authentication credentials from constructor options if available. - * This allows endpoints to access these credentials for OAuth token acquisition. - * Returns undefined if basic auth is not configured OR if required fields are missing. + * Handles newly acquired or refreshed OAuth tokens. + * This method processes token responses from any source and updates the authentication state: + * - OAuth endpoint responses (password grant, authorization code grant) + * - Automatic token refresh during request retry + * + * @param tokenResponse - The OAuth token response from the xMatters API + * @param clientId - The client ID used for token acquisition */ - getAuthCredentials(): BasicAuthCredentials | undefined { - if (isBasicAuthOptions(this.config)) { - // Only return credentials if we have valid username and password - if (this.config.username && this.config.password) { - return { - username: this.config.username, - password: this.config.password, - clientId: this.config.clientId, - }; - } - } - return undefined; + async handleNewOAuthTokens(tokenResponse: OAuth2TokenResponse, clientId: string): Promise { + this.mutableAuthState = { + type: 'oauth', + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + clientId: clientId, + expiresAt: new Date(Date.now() + (tokenResponse.expires_in * 1000)).toISOString(), + }; + await this.executeTokenRefreshCallback(tokenResponse.access_token, tokenResponse.refresh_token); } /** - * Check if this is basic auth configuration with specific field validation. - * Used by OAuth endpoint to provide specific error messages. + * Gets the current mutable authentication state. + * Callers can use the `type` property to determine the authentication method + * and access the appropriate properties in a type-safe manner. */ - validateBasicAuthFields(): { hasBasicAuth: boolean; missingField?: 'username' | 'password' } { - if (isBasicAuthOptions(this.config)) { - // Only provide specific field errors if we have partial credentials - // If both username and password are missing, treat as "no credentials" - const hasUsername = !!this.config.username; - const hasPassword = !!this.config.password; - - if (!hasUsername && !hasPassword) { - // Both missing - this is "no credentials" scenario - return { hasBasicAuth: false }; - } - - if (!hasUsername) { - return { hasBasicAuth: true, missingField: 'username' }; - } - if (!hasPassword) { - return { hasBasicAuth: true, missingField: 'password' }; - } - return { hasBasicAuth: true }; - } - return { hasBasicAuth: false }; + getCurrentAuthState(): MutableAuthState { + return this.mutableAuthState; } } diff --git a/src/core/types/internal/auth-state.ts b/src/core/types/internal/auth-state.ts new file mode 100644 index 0000000..aa7555f --- /dev/null +++ b/src/core/types/internal/auth-state.ts @@ -0,0 +1,19 @@ +/** + * Authentication state types used internally by the library. + * These types define the mutable authentication state that changes during RequestHandler lifetime. + */ + +/** + * Mutable authentication state - the only thing that changes during RequestHandler lifetime. + * Uses a discriminated union to ensure type-safe access to authentication properties. + */ +export type MutableAuthState = + | { type: 'basic'; username: string; password: string } + | { type: 'authCode'; authorizationCode: string; clientId: string; clientSecret?: string } + | { + type: 'oauth'; + accessToken: string; + refreshToken: string; + clientId: string; + expiresAt?: string; + }; diff --git a/src/core/types/internal/config.ts b/src/core/types/internal/config.ts index ff2b60c..9a9aac0 100644 --- a/src/core/types/internal/config.ts +++ b/src/core/types/internal/config.ts @@ -28,59 +28,67 @@ export type TokenRefreshCallback = ( /** * Base configuration options shared by all authentication methods. */ -export interface XmApiBaseOptions { +export interface XmApiBaseConfig { hostname: string; httpClient?: HttpClient; logger?: Logger; defaultHeaders?: Record; maxRetries?: number; - onTokenRefresh?: TokenRefreshCallback; // Optional callback for when OAuth tokens are acquired/refreshed + onTokenRefresh?: TokenRefreshCallback; } /** - * Configuration options for basic authentication. + * Basic auth configuration (can transition to OAuth). + * No clientId field - this is pure basic auth. */ -export interface XmApiBasicAuthOptions extends XmApiBaseOptions { +export interface BasicAuthConfig extends XmApiBaseConfig { username: string; password: string; - clientId?: string; // Optional for OAuth token acquisition } /** - * Basic authentication credentials structure. - * Used when extracting credentials for OAuth token acquisition. - * This is a subset of XmApiBasicAuthOptions containing only the auth fields. + * Auth code configuration (must call obtainTokens before API calls). + * ClientId is required - no discovery path. */ -export type BasicAuthCredentials = Pick< - XmApiBasicAuthOptions, - 'username' | 'password' | 'clientId' ->; +export interface AuthCodeConfig extends XmApiBaseConfig { + authorizationCode: string; // Changed from authCode to match xMatters API + clientId: string; + clientSecret?: string; // Optional client secret for enhanced security +} /** - * Configuration options for OAuth authentication with existing tokens. - * All three fields are required for proper OAuth functionality. + * OAuth configuration (ready for API calls). + * All OAuth fields are required. */ -export interface XmApiOAuthOptions extends XmApiBaseOptions { +export interface OAuthConfig extends XmApiBaseConfig { accessToken: string; refreshToken: string; clientId: string; + expiresAt?: string; // ISO timestamp when the access token expires } /** * Union type of all possible configuration options. */ -export type XmApiConfig = XmApiBasicAuthOptions | XmApiOAuthOptions; +export type XmApiConfig = BasicAuthConfig | AuthCodeConfig | OAuthConfig; + +/** + * Type guard to determine if config is for basic authentication. + */ +export function isBasicAuthConfig(config: XmApiConfig): config is BasicAuthConfig { + return 'username' in config && 'password' in config; +} /** - * Type guard to determine if options are for OAuth authentication with existing tokens. + * Type guard to determine if config is for auth code flow. */ -export function isOAuthOptions(options: XmApiConfig): options is XmApiOAuthOptions { - return 'accessToken' in options; +export function isAuthCodeConfig(config: XmApiConfig): config is AuthCodeConfig { + return 'authorizationCode' in config; } /** - * Type guard to determine if options are for basic authentication. + * Type guard to determine if config is for OAuth with existing tokens. */ -export function isBasicAuthOptions(options: XmApiConfig): options is XmApiBasicAuthOptions { - return 'username' in options && 'password' in options; +export function isOAuthConfig(config: XmApiConfig): config is OAuthConfig { + return 'accessToken' in config && 'refreshToken' in config; } diff --git a/src/core/types/internal/methods.ts b/src/core/types/internal/methods.ts index 20fa416..564b696 100644 --- a/src/core/types/internal/methods.ts +++ b/src/core/types/internal/methods.ts @@ -11,6 +11,8 @@ interface HttpMethodBaseOptions { path: string; /** Optional headers to send with the request */ headers?: Record; + /** Whether to skip adding authentication headers */ + skipAuth?: boolean; } /** diff --git a/src/core/types/internal/oauth.ts b/src/core/types/internal/oauth.ts index be738fd..e19d867 100644 --- a/src/core/types/internal/oauth.ts +++ b/src/core/types/internal/oauth.ts @@ -5,6 +5,7 @@ /** * Response from the OAuth2 token endpoint. + * Contains only the fields our library actually needs to function. */ export interface OAuth2TokenResponse { /** The access token to use for authenticated requests */ @@ -15,8 +16,6 @@ export interface OAuth2TokenResponse { expires_in: number; /** The type of token, typically 'Bearer' */ token_type: 'Bearer' | string; - /** The scopes granted to the token (space-separated string) */ - scope?: string; } /** diff --git a/src/core/utils/config-validation.ts b/src/core/utils/config-validation.ts new file mode 100644 index 0000000..6390c96 --- /dev/null +++ b/src/core/utils/config-validation.ts @@ -0,0 +1,45 @@ +import { XmApiError } from '../errors.ts'; +import type { XmApiConfig } from '../types/internal/config.ts'; + +/** + * Validates that the config is in exactly one valid state. + * Prevents invalid overlapping configurations. + */ +export function validateConfig(config: XmApiConfig): void { + const hasBasicAuth = 'username' in config && 'password' in config; + const hasAuthCode = 'authorizationCode' in config; + const hasOAuthTokens = 'accessToken' in config && 'refreshToken' in config; + + const configCount = [hasBasicAuth, hasAuthCode, hasOAuthTokens].filter(Boolean).length; + + if (configCount === 0) { + throw new XmApiError( + 'Invalid config: Must provide either basic auth credentials, authorization code, or OAuth tokens', + ); + } + + if (configCount > 1) { + throw new XmApiError( + 'Invalid config: Cannot mix basic auth, authorization code, and OAuth token fields', + ); + } + + // Validate required fields for each config type + if (hasBasicAuth && (!config.username || !config.password)) { + throw new XmApiError('Invalid config: Basic auth requires both username and password'); + } + + if (hasAuthCode && !config.authorizationCode) { + throw new XmApiError('Invalid config: Auth code flow requires authorizationCode'); + } + + if (hasAuthCode && !config.clientId) { + throw new XmApiError('Invalid config: Auth code flow requires clientId'); + } + + if (hasOAuthTokens && (!config.accessToken || !config.refreshToken || !config.clientId)) { + throw new XmApiError( + 'Invalid config: OAuth config requires accessToken, refreshToken, and clientId', + ); + } +} diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts new file mode 100644 index 0000000..7d8b7e9 --- /dev/null +++ b/src/core/utils/index.ts @@ -0,0 +1,6 @@ +/** + * Utility functions for the xMatters API library. + * These functions provide common validation and helper functionality. + */ + +export { validateConfig } from './config-validation.ts'; diff --git a/src/endpoints/oauth/index.test.ts b/src/endpoints/oauth/index.test.ts index 0c3eb30..e69de29 100644 --- a/src/endpoints/oauth/index.test.ts +++ b/src/endpoints/oauth/index.test.ts @@ -1,331 +0,0 @@ -/** - * Unit tests for OAuth endpoint - Password Grant Flow - */ - -import { expect } from 'https://deno.land/std@0.224.0/expect/mod.ts'; - -import { OAuthEndpoint } from './index.ts'; -import { RequestHandler } from '../../core/request-handler.ts'; -import type { HttpClient, HttpRequest, HttpResponse } from '../../core/types/internal/http.ts'; -import type { - Logger, - TokenRefreshCallback, - XmApiConfig, -} from '../../core/types/internal/config.ts'; -import type { TokenResponse } from './types.ts'; - -/** - * Mock HTTP client for testing OAuth endpoint - */ -class MockHttpClient implements HttpClient { - private responses: HttpResponse[] = []; - private callIndex = 0; - public requests: HttpRequest[] = []; - - constructor(responses: HttpResponse[]) { - this.responses = responses; - } - - send(request: HttpRequest): Promise> { - this.requests.push({ ...request }); - - if (this.callIndex >= this.responses.length) { - throw new Error('MockHttpClient: No more responses configured'); - } - - const response = this.responses[this.callIndex]; - this.callIndex++; - return Promise.resolve(response as HttpResponse); - } -} - -/** - * Helper to create a RequestHandler with mock dependencies for testing - */ -function createTestRequestHandler(options: { - hostname?: string; - username?: string; - password?: string; - clientId?: string; - accessToken?: string; - refreshToken?: string; - onTokenRefresh?: TokenRefreshCallback; - responses?: HttpResponse[]; -} = {}) { - const { - hostname = 'https://test.xmatters.com', - username, - password, - clientId, - accessToken, - refreshToken, - onTokenRefresh, - responses = [], - } = options; - - // Create silent mock logger - const mockLogger: Logger = { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - }; - - const mockHttpClient = new MockHttpClient(responses); - - // Create auth options based on provided parameters - let mockConfig: XmApiConfig; - if (accessToken && refreshToken && clientId) { - // OAuth configuration - all three are required - mockConfig = { - hostname, - accessToken, - refreshToken, - clientId, - onTokenRefresh, - maxRetries: 3, - httpClient: mockHttpClient, - logger: mockLogger, - }; - } else { - // Create basic auth options even with missing fields so OAuth endpoint can validate them specifically - // Use a partial basic auth config to test missing field validation - mockConfig = { - hostname, - username: username!, - password: password!, - onTokenRefresh, - maxRetries: 3, - httpClient: mockHttpClient, - logger: mockLogger, - } as XmApiConfig; - } - - const requestHandler = new RequestHandler(mockConfig); - - return { requestHandler, mockHttpClient, mockLogger }; -} - -/** - * Mock successful token response - */ -const mockTokenResponse: HttpResponse = { - status: 200, - headers: { 'content-type': 'application/json' }, - body: { - access_token: 'test-access-token', - refresh_token: 'test-refresh-token', - expires_in: 900, - token_type: 'bearer', - scope: 'read write', - }, -}; - -Deno.test('OAuthEndpoint - obtainTokens() - successful token acquisition', async () => { - const { requestHandler, mockHttpClient } = createTestRequestHandler({ - username: 'test-user', - password: 'test-password', - clientId: 'test-client-id', - responses: [mockTokenResponse], - }); - - const oauthEndpoint = new OAuthEndpoint(requestHandler); - const response = await oauthEndpoint.obtainTokens({ clientId: 'test-client-id' }); - - // Verify the request was made correctly - expect(mockHttpClient.requests).toHaveLength(1); - const request = mockHttpClient.requests[0]; - - expect(request.method).toBe('POST'); - expect(request.url).toBe('https://test.xmatters.com/api/xm/1/oauth2/token'); - expect(request.headers?.['Content-Type']).toBe('application/x-www-form-urlencoded'); - expect(request.headers?.['Accept']).toBe('application/json'); - // Note: skipAuth is handled by RequestHandler.send() and not passed to the HTTP client - expect(request.headers?.['Authorization']).toBeUndefined(); // Auth header should be skipped - - // Verify request body contains correct form data - const expectedBody = new URLSearchParams({ - grant_type: 'password', - client_id: 'test-client-id', - username: 'test-user', - password: 'test-password', - }).toString(); - expect(request.body).toBe(expectedBody); - - // Verify response structure - expect(response.status).toBe(200); - expect(response.body.access_token).toBe('test-access-token'); - expect(response.body.refresh_token).toBe('test-refresh-token'); - expect(response.body.expires_in).toBe(900); - expect(response.body.token_type).toBe('bearer'); - expect(response.body.scope).toBe('read write'); -}); - -Deno.test('OAuthEndpoint - obtainTokens() - throws error when no constructor credentials', async () => { - const { requestHandler, mockHttpClient: _ } = createTestRequestHandler({ - // No credentials provided - this will default to basic auth mode but with missing fields - responses: [], - }); - - const oauthEndpoint = new OAuthEndpoint(requestHandler); - - await expect(oauthEndpoint.obtainTokens({ clientId: 'test-client-id' })).rejects.toThrow( - 'XmApi must be initialized with basic auth credentials (username, password) to acquire OAuth tokens.', - ); - - // Verify no HTTP request was made - expect(_.requests).toHaveLength(0); -}); - -Deno.test('OAuthEndpoint - obtainTokens() - throws error when clientId is missing', async () => { - const { requestHandler, mockHttpClient: _ } = createTestRequestHandler({ - username: 'test-user', - password: 'test-password', - // Missing clientId - responses: [], - }); - - const oauthEndpoint = new OAuthEndpoint(requestHandler); - - await expect(oauthEndpoint.obtainTokens({ clientId: '' })).rejects.toThrow( - 'clientId is required for OAuth token acquisition. Provide it as a parameter to obtainTokens().', - ); - - expect(_.requests).toHaveLength(0); -}); - -Deno.test('OAuthEndpoint - obtainTokens() - throws error when username is missing', async () => { - const { requestHandler, mockHttpClient: _ } = createTestRequestHandler({ - password: 'test-password', - clientId: 'test-client-id', - // Missing username - responses: [], - }); - - const oauthEndpoint = new OAuthEndpoint(requestHandler); - - await expect(oauthEndpoint.obtainTokens({ clientId: 'test-client-id' })).rejects.toThrow( - 'username is required for OAuth token acquisition. Provide it in the XmApi constructor.', - ); - - expect(_.requests).toHaveLength(0); -}); - -Deno.test('OAuthEndpoint - obtainTokens() - throws error when password is missing', async () => { - const { requestHandler, mockHttpClient: _ } = createTestRequestHandler({ - username: 'test-user', - clientId: 'test-client-id', - // Missing password - responses: [], - }); - - const oauthEndpoint = new OAuthEndpoint(requestHandler); - - await expect(oauthEndpoint.obtainTokens({ clientId: 'test-client-id' })).rejects.toThrow( - 'password is required for OAuth token acquisition. Provide it in the XmApi constructor.', - ); - - expect(_.requests).toHaveLength(0); -}); - -Deno.test('OAuthEndpoint - obtainTokens() - throws error when API returns non-200 status', async () => { - const errorResponse: HttpResponse = { - status: 401, - headers: { 'content-type': 'application/json' }, - body: { error: 'invalid_client', error_description: 'Client authentication failed' }, - }; - - const { requestHandler, mockHttpClient } = createTestRequestHandler({ - username: 'test-user', - password: 'test-password', - clientId: 'test-client-id', - responses: [errorResponse], - }); - - const oauthEndpoint = new OAuthEndpoint(requestHandler); - - await expect(oauthEndpoint.obtainTokens({ clientId: 'test-client-id' })).rejects.toThrow( - 'Request failed with status 401', - ); - - expect(mockHttpClient.requests).toHaveLength(1); -}); - -Deno.test('OAuthEndpoint - obtainTokens() - calls token refresh callback when provided', async () => { - let callbackCalled = false; - let receivedAccessToken = ''; - let receivedRefreshToken = ''; - - const { requestHandler, mockHttpClient: _ } = createTestRequestHandler({ - username: 'test-user', - password: 'test-password', - clientId: 'test-client-id', - onTokenRefresh: (accessToken, refreshToken) => { - callbackCalled = true; - receivedAccessToken = accessToken; - receivedRefreshToken = refreshToken; - }, - responses: [mockTokenResponse], - }); - - const oauthEndpoint = new OAuthEndpoint(requestHandler); - await oauthEndpoint.obtainTokens({ clientId: 'test-client-id' }); - - // Verify callback was called with correct tokens - expect(callbackCalled).toBe(true); - expect(receivedAccessToken).toBe('test-access-token'); - expect(receivedRefreshToken).toBe('test-refresh-token'); -}); - -Deno.test('OAuthEndpoint - obtainTokens() - does not fail if token refresh callback throws error', async () => { - const { requestHandler, mockHttpClient: _ } = createTestRequestHandler({ - username: 'test-user', - password: 'test-password', - clientId: 'test-client-id', - onTokenRefresh: () => { - throw new Error('Callback error'); - }, - responses: [mockTokenResponse], - }); - - const oauthEndpoint = new OAuthEndpoint(requestHandler); - - // Should not throw error even though callback fails - const response = await oauthEndpoint.obtainTokens({ clientId: 'test-client-id' }); - expect(response.body.access_token).toBe('test-access-token'); -}); - -Deno.test('OAuthEndpoint - obtainTokens() - returns raw API response without field transformation', async () => { - // Response with actual API field names (snake_case) - const apiResponse: HttpResponse = { - status: 200, - headers: { 'content-type': 'application/json' }, - body: { - access_token: 'raw-access-token', - refresh_token: 'raw-refresh-token', - expires_in: 3600, - token_type: 'Bearer', - scope: 'api read write', - }, - }; - - const { requestHandler, mockHttpClient: _ } = createTestRequestHandler({ - username: 'test-user', - password: 'test-password', - clientId: 'test-client-id', - responses: [apiResponse], - }); - - const oauthEndpoint = new OAuthEndpoint(requestHandler); - const response = await oauthEndpoint.obtainTokens({ clientId: 'test-client-id' }); - - // Verify that field names are preserved exactly as returned by API - expect(response.body.access_token).toBe('raw-access-token'); - expect(response.body.refresh_token).toBe('raw-refresh-token'); - expect(response.body.expires_in).toBe(3600); - expect(response.body.token_type).toBe('Bearer'); - expect(response.body.scope).toBe('api read write'); - - // Verify that the response is the exact same object returned by HTTP client - expect(response).toBe(apiResponse); -}); diff --git a/src/endpoints/oauth/index.ts b/src/endpoints/oauth/index.ts index 4d2e656..421ccfc 100644 --- a/src/endpoints/oauth/index.ts +++ b/src/endpoints/oauth/index.ts @@ -1,7 +1,7 @@ import { RequestHandler } from '../../core/request-handler.ts'; +import { OAuth2TokenResponse } from '../../core/types/internal/oauth.ts'; +import { HttpResponse } from '../../core/types/internal/http.ts'; import { XmApiError } from '../../core/errors.ts'; -import { TokenResponse } from './types.ts'; -import type { HttpResponse } from '../../core/types/internal/http.ts'; export class OAuthEndpoint { constructor( @@ -9,83 +9,117 @@ export class OAuthEndpoint { ) {} /** - * Obtain OAuth tokens using username and password from constructor, then automatically switch to OAuth mode. - * After calling this method, all subsequent API calls will use the acquired OAuth tokens. - * This is the "password" grant type in OAuth2 terminology. + * Smart method to obtain OAuth tokens based on the current configuration. * - * The username and password must be provided in the XmApi constructor. - * The clientId must be provided as a parameter to this method. + * Since config validation guarantees exactly one valid state, we can + * determine the flow directly without any convoluted credential checks. * - * @param options - Options for token acquisition - * @param options.clientId - OAuth client ID for token acquisition - * @returns Promise resolving to HTTP response containing token information - * - * @example - * ```typescript - * const xm = new XmApi({ - * hostname: 'https://example.xmatters.com', - * username: 'your-username', - * password: 'your-password' - * }); - * - * const { body: tokens } = await xm.oauth.obtainTokens({ - * clientId: 'your-client-id' - * }); - * ``` + * @param options - Optional parameters for token acquisition + * @param options.clientId - Client ID for password grant (skips discovery) + * @param options.clientSecret - Client secret for enhanced security (required for non-org clients) + * @returns Promise resolving to token response */ - async obtainTokens(options: { clientId: string }): Promise> { - const { clientId } = options; - - // Get constructor credentials from RequestHandler - const constructorCredentials = this.http.getAuthCredentials(); - if (!constructorCredentials) { - // Check if this is basic auth config with missing fields for more specific error messages - const validation = this.http.validateBasicAuthFields(); - if (validation.hasBasicAuth) { - if (validation.missingField === 'username') { - throw new XmApiError( - 'username is required for OAuth token acquisition. Provide it in the XmApi constructor.', - ); - } - if (validation.missingField === 'password') { - throw new XmApiError( - 'password is required for OAuth token acquisition. Provide it in the XmApi constructor.', - ); - } + async obtainTokens( + options: { clientId?: string; clientSecret?: string } = {}, + ): Promise> { + const authState = this.http.getCurrentAuthState(); + switch (authState.type) { + case 'basic': { + return await this.getOAuthTokenByPassword( + { username: authState.username, password: authState.password }, + options.clientId, + options.clientSecret, + ); + } + case 'authCode': { + const clientSecret = options.clientSecret || authState.clientSecret; + return await this.getOAuthTokenByAuthorizationCode( + { + authorizationCode: authState.authorizationCode, + clientId: authState.clientId, + }, + clientSecret, + ); + } + case 'oauth': { + throw new XmApiError('Already have OAuth tokens - no need to call obtainTokens()'); + } + default: { + // This should never happen due to config validation, but TypeScript requires it + throw new XmApiError('Invalid configuration type for token acquisition'); } - throw new XmApiError( - 'XmApi must be initialized with basic auth credentials (username, password) to acquire OAuth tokens.', - ); } - const { username, password } = constructorCredentials; + } - // Validate that we have all required credentials - if (!clientId) { - throw new XmApiError( - 'clientId is required for OAuth token acquisition. Provide it as a parameter to obtainTokens().', - ); - } - const requestBody = new URLSearchParams({ - grant_type: 'password', - client_id: clientId, - username, - password, - }); - const response = await this.http.send({ - method: 'POST', + /** + * Base method for making OAuth token requests. + * All OAuth flows use this common method. + * + * @param payload - Form-encoded payload for the token request + * @param clientId - Client ID for config transition + * @returns Promise resolving to token response + */ + private async getOAuthToken( + payload: string, + clientId: string, + ): Promise> { + const response = await this.http.post({ path: '/oauth2/token', + body: payload, headers: { 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', }, - body: requestBody.toString(), - skipAuth: true, // Don't add auth headers for token acquisition + skipAuth: true, // Don't add auth header for token requests }); - const tokenData = response.body; + // If successful, handle the new OAuth tokens (this also executes the token refresh callback) + await this.http.handleNewOAuthTokens(response.body, clientId); + return response; + } - // Handle the newly acquired tokens - await this.http.handleNewTokens(tokenData, clientId); + /** + * Performs OAuth2 password grant flow using basic auth credentials. + * Following the pattern: grant_type=password&client_id=...&username=...&password=...&client_secret=... + * + * @param credentials - Basic auth credentials (pure username/password) + * @param clientId - Client ID (if not provided, discovery would be attempted) + * @param clientSecret - Optional client secret for enhanced security + * @returns Promise resolving to token response + */ + private async getOAuthTokenByPassword( + credentials: { username: string; password: string }, + clientId?: string, + clientSecret?: string, + ): Promise> { + if (!clientId) { + throw new XmApiError( + 'Client ID discovery not yet implemented - please provide explicit clientId', + ); + } + let payload = + `grant_type=password&client_id=${clientId}&username=${credentials.username}&password=${credentials.password}`; + if (clientSecret) { + payload += `&client_secret=${clientSecret}`; + } + return await this.getOAuthToken(payload, clientId); + } - return response; + /** + * Performs OAuth2 authorization code flow. + * Following the pattern: grant_type=authorization_code&authorization_code=...&client_secret=... + * + * @param credentials - Auth code credentials with client ID + * @param clientSecret - Optional client secret (from config or obtainTokens params) + * @returns Promise resolving to token response + */ + private async getOAuthTokenByAuthorizationCode( + credentials: { authorizationCode: string; clientId: string }, + clientSecret?: string, + ): Promise> { + let payload = + `grant_type=authorization_code&authorization_code=${credentials.authorizationCode}`; + if (clientSecret) { + payload += `&client_secret=${clientSecret}`; + } + return await this.getOAuthToken(payload, credentials.clientId); } } diff --git a/src/endpoints/oauth/types.ts b/src/endpoints/oauth/types.ts index 4c31ae9..6029f9c 100644 --- a/src/endpoints/oauth/types.ts +++ b/src/endpoints/oauth/types.ts @@ -5,17 +5,15 @@ /** * The response returned when successfully obtaining OAuth tokens. - * This matches the exact format returned by the xMatters API. + * Contains only the fields our library consumers need for functionality. */ export interface TokenResponse { /** The access token to use for authenticated requests */ access_token: string; + /** The type of token, typically 'Bearer' */ + token_type: string; /** Token to use to get a new access token when it expires */ refresh_token: string; /** How many seconds until the access token expires */ expires_in: number; - /** The type of token, typically 'Bearer' */ - token_type: string; - /** The scopes granted to the token (space-separated string) */ - scope?: string; } diff --git a/src/index.ts b/src/index.ts index 15ba293..6e34c32 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { RequestHandler } from './core/request-handler.ts'; -import { XmApiConfig } from './core/types/internal/config.ts'; +import { validateConfig } from './core/utils/index.ts'; +import type { XmApiConfig } from './core/types/internal/config.ts'; import { GroupsEndpoint } from './endpoints/groups/index.ts'; import { OAuthEndpoint } from './endpoints/oauth/index.ts'; @@ -48,6 +49,8 @@ export class XmApi { public readonly oauth: OAuthEndpoint; constructor(config: XmApiConfig) { + // Validate config to ensure it's in exactly one valid state + validateConfig(config); this.http = new RequestHandler(config); // Initialize endpoints this.groups = new GroupsEndpoint(this.http); @@ -59,9 +62,9 @@ export class XmApi { export * from './core/types/internal/config.ts'; export * from './core/types/internal/http.ts'; export * from './core/types/internal/oauth.ts'; +export * from './core/types/internal/auth-state.ts'; export * from './core/types/endpoint/response.ts'; export * from './core/types/endpoint/composers.ts'; export * from './core/types/endpoint/params.ts'; export * from './endpoints/groups/types.ts'; -export * from './endpoints/oauth/types.ts'; export { XmApiError } from './core/errors.ts'; From 55ec22f8b58599e0f86cf989dcf80c4c53e17f81 Mon Sep 17 00:00:00 2001 From: johan Date: Sat, 14 Jun 2025 16:24:07 -0700 Subject: [PATCH 043/101] Clean up doc --- docs/oauth-dx-refactor-plan.md | 366 +------------------------------ docs/oauth-refactor-checklist.md | 206 ----------------- 2 files changed, 5 insertions(+), 567 deletions(-) delete mode 100644 docs/oauth-refactor-checklist.md diff --git a/docs/oauth-dx-refactor-plan.md b/docs/oauth-dx-refactor-plan.md index 9cdb55b..ca03cac 100644 --- a/docs/oauth-dx-refactor-plan.md +++ b/docs/oauth-dx-refactor-plan.md @@ -1,12 +1,4 @@ -# OAuth DX Refactor Plan - -## Overview - -Implement a new DX approach where configuration determines the authentication flow, and -`obtainTokens()` is a single smart method that handles different OAuth flows based on the -configuration type. - -## New DX Design +# OAuth DX ### Scenario 1: Basic Auth → OAuth (Password Grant) @@ -15,7 +7,6 @@ const xm = new XmApi({ hostname: 'https://company.xmatters.com', username: 'user@company.com', password: 'secret', - // No clientId - this is pure basic auth configuration }); // Use basic auth for initial API calls @@ -26,6 +17,9 @@ await xm.oauth.obtainTokens(); // Or provide explicit clientId to skip discovery await xm.oauth.obtainTokens({ clientId: 'my-client-id' }); + +// Subsequent API calls are now oauth authenticated +await xm.groups.get(); ``` ### Scenario 2: Auth Code → OAuth @@ -40,7 +34,7 @@ const xm = new XmApi({ // Must call obtainTokens() before other API calls await xm.oauth.obtainTokens(); -// Now can make authenticated API calls +// Subsequent API calls are now oauth authenticated await xm.groups.get(); ``` @@ -57,353 +51,3 @@ const xm = new XmApi({ // Already authenticated - can make API calls immediately await xm.groups.get(); ``` - -## Implementation Tasks - -### 1. Type System Rewrite - -#### 1.1 New Config Types (`src/core/types/internal/config.ts`) - -```typescript -// Base configuration -interface XmApiBaseConfig { - hostname: string; - httpClient?: HttpClient; - logger?: Logger; - defaultHeaders?: Record; - maxRetries?: number; - onTokenRefresh?: TokenRefreshCallback; -} - -// Basic auth configuration (can transition to OAuth) -interface BasicAuthConfig extends XmApiBaseConfig { - username: string; - password: string; - // No clientId field - this is pure basic auth -} - -// Auth code configuration (must call obtainTokens before API calls) -interface AuthCodeConfig extends XmApiBaseConfig { - authCode: string; - clientId: string; // Required - no discovery path -} - -// OAuth configuration (ready for API calls) -interface OAuthConfig extends XmApiBaseConfig { - accessToken: string; - refreshToken: string; - clientId: string; -} - -// Union type -type XmApiConfig = BasicAuthConfig | AuthCodeConfig | OAuthConfig; -``` - -#### 1.2 New Type Guards - -```typescript -function isBasicAuthConfig(config: XmApiConfig): config is BasicAuthConfig { - return 'username' in config && 'password' in config; -} - -function isAuthCodeConfig(config: XmApiConfig): config is AuthCodeConfig { - return 'authCode' in config; -} - -function isOAuthConfig(config: XmApiConfig): config is OAuthConfig { - return 'accessToken' in config && 'refreshToken' in config; -} -``` - -### 2. RequestHandler Updates (`src/core/request-handler.ts`) - -#### 2.1 Constructor Updates - -- Update constructor to handle new config types -- Initialize token state for OAuth configs -- Store auth code data for auth code configs - -#### 2.2 New Helper Methods - -```typescript -// Check if this is basic auth configuration -hasBasicAuthConfig(): boolean - -// Check if this is auth code configuration -hasAuthCodeConfig(): boolean - -// Check if this is OAuth configuration -hasOAuthConfig(): boolean - -// Get auth code data for OAuth endpoint -getAuthCodeData(): { clientId: string; authCode: string } | undefined - -// Check if auth code flow is pending (tokens not yet obtained) -isAuthCodePending(): boolean -``` - -#### 2.3 Enhanced send() Method - -Add validation in `send()` method to detect when auth code flow hasn't been completed: - -```typescript -async send(request: RequestBuildOptions): Promise> { - // Check if auth code flow is pending - if (this.hasAuthCodeConfig() && this.isAuthCodePending()) { - throw new XmApiError( - 'Auth code configuration detected. Call xm.oauth.obtainTokens() first to exchange code for tokens.' - ); - } - - // ... rest of existing send logic -} -``` - -#### 2.4 Update Existing Methods - -- Update `getAuthCredentials()` to work with new BasicAuthConfig -- Update `validateBasicAuthFields()` if still needed -- Update `createAuthHeader()` to handle new config types - -### 3. OAuth Endpoint Rewrite (`src/endpoints/oauth/index.ts`) - -#### 3.1 Smart obtainTokens() Method - -```typescript -async obtainTokens(options?: { clientId?: string }): Promise> { - const flow = this.detectFlow(); - - switch (flow.type) { - case 'password': - return this.handlePasswordFlow(options?.clientId ?? flow.clientId); - case 'authCode': - return this.handleAuthCodeFlow(flow.clientId, flow.authCode); - default: - throw new XmApiError('obtainTokens() requires basic auth or auth code configuration'); - } -} -``` - -#### 3.2 Flow Detection Logic - -```typescript -private detectFlow(): FlowInfo { - if (this.http.hasBasicAuthConfig()) { - const creds = this.http.getAuthCredentials(); - return { - type: 'password', - username: creds?.username, - password: creds?.password - // No clientId from basic auth config - will be provided or discovered - }; - } - - if (this.http.hasAuthCodeConfig()) { - const authData = this.http.getAuthCodeData(); - return { - type: 'authCode', - clientId: authData!.clientId, - authCode: authData!.authCode - }; - } - - return { type: 'invalid' }; -} -``` - -#### 3.3 Flow Handler Methods - -```typescript -private async handlePasswordFlow(clientId?: string): Promise> { - // Get credentials from RequestHandler - const creds = this.http.getAuthCredentials(); - if (!creds) { - throw new XmApiError('Basic auth credentials required for password flow'); - } - - // Use provided clientId or discover it - const finalClientId = clientId ?? await this.discoverClientId(); - - // Make password grant request - const requestBody = new URLSearchParams({ - grant_type: 'password', - client_id: finalClientId, - username: creds.username, - password: creds.password, - }); - - // ... rest of implementation -} - -private async handleAuthCodeFlow(clientId: string, authCode: string): Promise> { - // Make auth code exchange request - const requestBody = new URLSearchParams({ - grant_type: 'authorization_code', - client_id: clientId, - code: authCode, - // redirect_uri might be needed depending on xMatters API - }); - - // ... rest of implementation -} - -private async discoverClientId(): Promise { - // TODO: Implement clientId discovery via another endpoint - throw new XmApiError('Client ID discovery not yet implemented. Please provide clientId parameter.'); -} -``` - -### 4. Test Updates - -#### 4.1 OAuth Endpoint Tests (`src/endpoints/oauth/index.test.ts`) - -- Update test helper `createTestRequestHandler()` to use new config types -- Add tests for auth code flow -- Update existing password flow tests -- Add tests for flow detection logic -- Add tests for error cases (auth code pending, invalid config, etc.) - -#### 4.2 RequestHandler Tests (`src/core/request-handler.test.ts`) - -- Update tests to use new config types -- Add tests for new helper methods -- Add tests for auth code pending validation in send() -- Update existing authentication tests - -#### 4.3 Integration Tests - -- Test complete flows end-to-end -- Test config validation -- Test error scenarios - -### 5. Supporting Type Updates - -#### 5.1 OAuth Types (`src/core/types/internal/oauth.ts`) - -```typescript -// Add auth code related types -interface AuthCodeData { - authCode: string; - clientId: string; -} - -// Flow detection types -type FlowType = 'password' | 'authCode' | 'invalid'; - -interface FlowInfo { - type: FlowType; - clientId?: string; - username?: string; - password?: string; - authCode?: string; -} -``` - -#### 5.2 Update BasicAuthCredentials - -```typescript -// Update to match new BasicAuthConfig (no clientId) -export type BasicAuthCredentials = Pick< - BasicAuthConfig, - 'username' | 'password' ->; -``` - -### 6. Documentation Updates - -#### 6.1 Update Method Documentation - -- Update JSDoc for `obtainTokens()` method -- Add examples for all three scenarios -- Document the flow detection behavior - -#### 6.2 Update README and Examples - -- Update usage examples in README -- Update sandbox examples -- Create migration guide from old API - -### 7. Migration Strategy - -#### 7.1 Backward Compatibility (Optional) - -Consider keeping old method names as deprecated aliases: - -```typescript -// Deprecated alias -async getTokensByCredentials(): Promise> { - console.warn('getTokensByCredentials() is deprecated. Use obtainTokens() instead.'); - return this.obtainTokens(); -} -``` - -#### 7.2 Breaking Changes - -- Constructor parameter types change -- Some config validation behavior changes -- Error messages may change - -### 8. Implementation Order - -1. **Phase 1**: Update type system and type guards -2. **Phase 2**: Update RequestHandler with new helper methods -3. **Phase 3**: Implement auth code pending validation in send() -4. **Phase 4**: Rewrite OAuth endpoint with smart obtainTokens() -5. **Phase 5**: Update all tests -6. **Phase 6**: Add auth code flow implementation -7. **Phase 7**: Implement clientId discovery (future) - -### 9. Key Validation Points - -#### 9.1 Config Validation - -- BasicAuthConfig: require username + password only (no clientId) -- AuthCodeConfig: require authCode + clientId -- OAuthConfig: require all three fields - -#### 9.2 Runtime Validation - -- Auth code flow: must call obtainTokens() before other API calls -- Password flow: can use basic auth immediately, obtainTokens() switches to OAuth -- OAuth flow: can make API calls immediately - -#### 9.3 Error Scenarios - -- Calling API methods with pending auth code -- Invalid config combinations -- Missing required fields -- Network errors during token exchange - -### 10. Future Enhancements - -#### 10.1 ClientId Discovery - -Implement endpoint to discover clientId for password flow users. - -#### 10.2 Additional OAuth Flows - -- Device code flow -- Client credentials flow -- PKCE support for auth code flow - -#### 10.3 Token Management - -- Automatic token refresh -- Token persistence -- Token validation - -## Success Criteria - -- ✅ All three scenarios work as designed -- ✅ Type safety prevents invalid configurations -- ✅ Clear error messages guide users to correct usage -- ✅ All tests pass -- ✅ Documentation is clear and comprehensive -- ✅ Code is maintainable and extensible - -## Notes - -- The auth code flow detection in `send()` prevents users from forgetting to call `obtainTokens()` -- ClientId discovery can be implemented later without breaking the API -- The flow detection pattern makes it easy to add new OAuth flows in the future -- Type system ensures compile-time validation of configuration validity diff --git a/docs/oauth-refactor-checklist.md b/docs/oauth-refactor-checklist.md deleted file mode 100644 index 6290a60..0000000 --- a/docs/oauth-refactor-checklist.md +++ /dev/null @@ -1,206 +0,0 @@ -# OAuth DX Refactor Implementation Checklist - -## 🎯 Goal - -Implement refactor as scoped out in [oauth-dx-refactor-plan.md](./oauth-dx-refactor-plan.md) - -## 📋 Implementation Phases - -### Phase 1: Type System Rewrite ⏳ - -- [ ] **1.1** Update `src/core/types/internal/config.ts` - - [ ] Create `XmApiBaseConfig` interface - - [ ] Create `BasicAuthConfig` interface (username + password only) - - [ ] Create `AuthCodeConfig` interface (authCode + clientId) - - [ ] Create `OAuthConfig` interface (accessToken + refreshToken + clientId) - - [ ] Create `XmApiConfig` union type - - [ ] Export all new types -- [ ] **1.2** Add type guards to config.ts - - [ ] `isBasicAuthConfig()` function - - [ ] `isAuthCodeConfig()` function - - [ ] `isOAuthConfig()` function - - [ ] Export type guards -- [ ] **1.3** Update `src/core/types/internal/oauth.ts` - - [ ] Add `AuthCodeData` interface - - [ ] Add `FlowType` type - - [ ] Add `FlowInfo` interface - - [ ] Update `BasicAuthCredentials` type (remove clientId) -- [ ] **1.4** Verify types build without errors - - [ ] Run `deno check src/index.ts` - - [ ] Fix any type errors - -### Phase 2: RequestHandler Updates ⏳ - -- [ ] **2.1** Update constructor in `src/core/request-handler.ts` - - [ ] Update config parameter type to `XmApiConfig` - - [ ] Add logic to handle different config types - - [ ] Store auth code data if present - - [ ] Initialize OAuth token state if present -- [ ] **2.2** Add new helper methods - - [ ] `hasBasicAuthConfig(): boolean` - - [ ] `hasAuthCodeConfig(): boolean` - - [ ] `hasOAuthConfig(): boolean` - - [ ] `getAuthCodeData(): { clientId: string; authCode: string } | undefined` - - [ ] `isAuthCodePending(): boolean` - - [ ] Ensure congruency between `getAuthCredentials()` and `getAuthCodeData()` - - [ ] Both return a typed object or `undefined` (never throw) - - [ ] Both are instance methods on `RequestHandler` - - [ ] Both have parallel naming and documentation - - [ ] Both types (`BasicAuthCredentials`, `AuthCodeData`) are defined in - `src/core/types/internal/oauth.ts` - - [ ] Both are used in parallel in flow detection and endpoint logic - - [ ] Do not explicitly use `return undefined;` in either method—if a value is not present, - simply allow implicit undefined, or if unavoidable, use `return null;` instead. -- [ ] **2.3** Update existing methods - - [ ] Update `getAuthCredentials()` for new BasicAuthConfig - - [ ] Update `createAuthHeader()` for new config types - - [ ] Review and update `validateBasicAuthFields()` if needed -- [ ] **2.4** Verify RequestHandler builds - - [ ] Run `deno check src/core/request-handler.ts` - - [ ] Fix any compilation errors - -### Phase 3: Auth Code Validation in send() ⏳ - -- [ ] **3.1** Update `send()` method in RequestHandler - - [ ] Add auth code pending check at start of method - - [ ] Throw clear error if auth code flow not completed - - [ ] Ensure existing logic still works -- [ ] **3.2** Test validation logic - - [ ] Create minimal test to verify error is thrown - - [ ] Ensure existing tests still pass - -### Phase 4: OAuth Endpoint Rewrite ⏳ - -- [ ] **4.1** Update `src/endpoints/oauth/index.ts` - Smart obtainTokens() - - [ ] Replace existing methods with `obtainTokens(options?: { clientId?: string })` - - [ ] Add flow detection logic (`detectFlow()` private method) - - [ ] Add password flow handler (`handlePasswordFlow()` private method) - - [ ] Add auth code flow handler (`handleAuthCodeFlow()` private method) - - [ ] Add client ID discovery stub (`discoverClientId()` private method) -- [ ] **4.2** Update OAuth endpoint exports - - [ ] Ensure new method is properly exported - - [ ] Add deprecated aliases if backward compatibility needed -- [ ] **4.3** Update OAuth types in `src/endpoints/oauth/types.ts` - - [ ] Add any new types needed for the methods - - [ ] Update existing types if necessary -- [ ] **4.4** Verify OAuth endpoint builds - - [ ] Run `deno check src/endpoints/oauth/index.ts` - - [ ] Fix any compilation errors - -### Phase 5: Test Updates ⏳ - -- [ ] **5.1** Update RequestHandler tests (`src/core/request-handler.test.ts`) - - [ ] Update test helpers to use new config types - - [ ] Add tests for new helper methods - - [ ] Add tests for auth code pending validation in send() - - [ ] Update existing authentication tests - - [ ] Ensure all tests pass: `deno test src/core/request-handler.test.ts` -- [ ] **5.2** Update OAuth endpoint tests (`src/endpoints/oauth/index.test.ts`) - - [ ] Update `createTestRequestHandler()` helper for new config types - - [ ] Add tests for smart `obtainTokens()` method - - [ ] Add tests for flow detection logic - - [ ] Add tests for password flow handler - - [ ] Add tests for auth code flow handler - - [ ] Add tests for error cases (invalid config, auth code pending, etc.) - - [ ] Update existing tests to work with new API - - [ ] Ensure all tests pass: `deno test src/endpoints/oauth/index.test.ts` -- [ ] **5.3** Run full test suite - - [ ] Run `deno test` and ensure all tests pass - - [ ] Fix any failing tests - -### Phase 6: Integration & Validation ⏳ - -- [ ] **6.1** Update main exports (`src/index.ts`) - - [ ] Ensure new config types are exported - - [ ] Ensure new OAuth API is exported - - [ ] Verify no breaking changes to public API -- [ ] **6.2** Test scenarios in sandbox - - [ ] Test Scenario 1: Basic Auth → OAuth (Password Grant) - - [ ] Test Scenario 2: Auth Code → OAuth - - [ ] Test Scenario 3: Pre-existing OAuth Tokens - - [ ] Test error cases and edge cases -- [ ] **6.3** Final validation - - [ ] Run full test suite: `deno test` - - [ ] Run type checking: `deno check src/index.ts` - - [ ] Test build process works - - [ ] Verify no runtime errors in sandbox - -### Phase 7: Documentation & Cleanup ⏳ - -- [ ] **7.1** Update JSDoc comments - - [ ] Update `obtainTokens()` method documentation - - [ ] Add examples for all three scenarios - - [ ] Document flow detection behavior - - [ ] Update other affected method docs -- [ ] **7.2** Update sandbox examples - - [ ] Update `sandbox/index.ts` with new API examples - - [ ] Test examples work correctly -- [ ] **7.3** Final cleanup - - [ ] Remove any old unused code - - [ ] Clean up console.log or debug statements - - [ ] Ensure code follows project style guidelines - -## 🚨 Critical Checkpoints - -### Before Phase 2 - -- [ ] All new types are properly defined and exported -- [ ] Type guards work correctly -- [ ] No TypeScript compilation errors - -### Before Phase 4 - -- [ ] RequestHandler properly handles all three config types -- [ ] Auth code validation in send() works correctly -- [ ] All RequestHandler tests pass - -### Before Phase 6 - -- [ ] OAuth endpoint smart method works for all flows -- [ ] All OAuth endpoint tests pass -- [ ] No breaking changes to existing working code - -### Before Phase 7 - -- [ ] All three scenarios work end-to-end -- [ ] Full test suite passes -- [ ] No TypeScript errors -- [ ] Sandbox examples work - -## 🐛 Common Issues to Watch For - -- [ ] Type guard functions must be precise (no false positives) -- [ ] Auth code pending check must not interfere with basic auth or OAuth flows -- [ ] Token state management between flows -- [ ] Error messages must be clear and actionable -- [ ] Backward compatibility with existing working code -- [ ] Test mocks must align with new config types - -## 📝 Implementation Notes - -- **Current Status**: Not started -- **Last Updated**: [Date when work begins] -- **Blockers**: None currently identified -- **Next Action**: Begin Phase 1.1 - Update config types - -## 🔄 Resume Instructions - -1. Check the last completed checkbox above -2. Read the "Next Action" note -3. Review any "Blockers" or implementation notes -4. Continue from the next unchecked item -5. Update "Last Updated" when making progress - -## ✅ Success Criteria Verification - -- [ ] All three scenarios work as designed -- [ ] Type safety prevents invalid configurations -- [ ] Clear error messages guide users to correct usage -- [ ] All tests pass (`deno test`) -- [ ] No TypeScript errors (`deno check src/index.ts`) -- [ ] Documentation is clear and comprehensive -- [ ] Code is maintainable and extensible - ---- - -_This checklist tracks the OAuth DX refactor implementation. Update status as work progresses._ From bd7cb901aae6b067038a7c5baf2eeff2ede9f23b Mon Sep 17 00:00:00 2001 From: johan Date: Sat, 14 Jun 2025 22:44:23 -0700 Subject: [PATCH 044/101] Align oauth methods with standard of single arg object --- src/endpoints/oauth/index.ts | 76 +++++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/src/endpoints/oauth/index.ts b/src/endpoints/oauth/index.ts index 421ccfc..d6ce562 100644 --- a/src/endpoints/oauth/index.ts +++ b/src/endpoints/oauth/index.ts @@ -22,24 +22,24 @@ export class OAuthEndpoint { async obtainTokens( options: { clientId?: string; clientSecret?: string } = {}, ): Promise> { + const { clientId, clientSecret } = options; const authState = this.http.getCurrentAuthState(); switch (authState.type) { case 'basic': { - return await this.getOAuthTokenByPassword( - { username: authState.username, password: authState.password }, - options.clientId, - options.clientSecret, - ); + return await this.getOAuthTokenByPassword({ + username: authState.username, + password: authState.password, + clientId, + clientSecret, + }); } case 'authCode': { - const clientSecret = options.clientSecret || authState.clientSecret; - return await this.getOAuthTokenByAuthorizationCode( - { - authorizationCode: authState.authorizationCode, - clientId: authState.clientId, - }, - clientSecret, - ); + const resolvedClientSecret = clientSecret || authState.clientSecret; + return await this.getOAuthTokenByAuthorizationCode({ + authorizationCode: authState.authorizationCode, + clientId: authState.clientId, + clientSecret: resolvedClientSecret, + }); } case 'oauth': { throw new XmApiError('Already have OAuth tokens - no need to call obtainTokens()'); @@ -55,14 +55,15 @@ export class OAuthEndpoint { * Base method for making OAuth token requests. * All OAuth flows use this common method. * - * @param payload - Form-encoded payload for the token request - * @param clientId - Client ID for config transition + * @param options - Token request options + * @param options.payload - Form-encoded payload for the token request + * @param options.clientId - Client ID for config transition * @returns Promise resolving to token response */ private async getOAuthToken( - payload: string, - clientId: string, + options: { payload: string; clientId: string }, ): Promise> { + const { payload, clientId } = options; const response = await this.http.post({ path: '/oauth2/token', body: payload, @@ -80,46 +81,57 @@ export class OAuthEndpoint { * Performs OAuth2 password grant flow using basic auth credentials. * Following the pattern: grant_type=password&client_id=...&username=...&password=...&client_secret=... * - * @param credentials - Basic auth credentials (pure username/password) - * @param clientId - Client ID (if not provided, discovery would be attempted) - * @param clientSecret - Optional client secret for enhanced security + * @param options - Password grant options + * @param options.username - Username for authentication + * @param options.password - Password for authentication + * @param options.clientId - Client ID (if not provided, discovery would be attempted) + * @param options.clientSecret - Optional client secret for enhanced security * @returns Promise resolving to token response */ private async getOAuthTokenByPassword( - credentials: { username: string; password: string }, - clientId?: string, - clientSecret?: string, + options: { + username: string; + password: string; + clientId?: string; + clientSecret?: string; + }, ): Promise> { + const { username, password, clientId, clientSecret } = options; if (!clientId) { throw new XmApiError( 'Client ID discovery not yet implemented - please provide explicit clientId', ); } let payload = - `grant_type=password&client_id=${clientId}&username=${credentials.username}&password=${credentials.password}`; + `grant_type=password&client_id=${clientId}&username=${username}&password=${password}`; if (clientSecret) { payload += `&client_secret=${clientSecret}`; } - return await this.getOAuthToken(payload, clientId); + return await this.getOAuthToken({ payload, clientId }); } /** * Performs OAuth2 authorization code flow. * Following the pattern: grant_type=authorization_code&authorization_code=...&client_secret=... * - * @param credentials - Auth code credentials with client ID - * @param clientSecret - Optional client secret (from config or obtainTokens params) + * @param options - Authorization code grant options + * @param options.authorizationCode - Authorization code from the auth flow + * @param options.clientId - Client ID for the application + * @param options.clientSecret - Optional client secret (from config or obtainTokens params) * @returns Promise resolving to token response */ private async getOAuthTokenByAuthorizationCode( - credentials: { authorizationCode: string; clientId: string }, - clientSecret?: string, + options: { + authorizationCode: string; + clientId: string; + clientSecret?: string; + }, ): Promise> { - let payload = - `grant_type=authorization_code&authorization_code=${credentials.authorizationCode}`; + const { authorizationCode, clientId, clientSecret } = options; + let payload = `grant_type=authorization_code&authorization_code=${authorizationCode}`; if (clientSecret) { payload += `&client_secret=${clientSecret}`; } - return await this.getOAuthToken(payload, credentials.clientId); + return await this.getOAuthToken({ payload, clientId }); } } From 0fdb9db3b20ffbb905784403687af5633893390d Mon Sep 17 00:00:00 2001 From: johan Date: Sun, 15 Jun 2025 13:54:37 -0700 Subject: [PATCH 045/101] Add User-Agent defaut header. Fix type imports after a restart of the Deno LSP --- deno.json | 3 +++ docs/improvements.md | 19 +++++++++++++++++++ src/core/defaults/http-client.ts | 2 +- src/core/defaults/logger.ts | 2 +- src/core/request-builder.test.ts | 2 +- src/core/request-builder.ts | 2 +- src/core/request-handler.ts | 20 +++++++++++++------- src/core/resource-client.ts | 2 +- src/endpoints/groups/index.ts | 4 ++-- src/endpoints/groups/types.ts | 4 ++-- src/endpoints/oauth/index.ts | 6 +++--- 11 files changed, 47 insertions(+), 19 deletions(-) diff --git a/deno.json b/deno.json index 932d79f..665466e 100644 --- a/deno.json +++ b/deno.json @@ -1,4 +1,7 @@ { + "name": "@johanfive/xmas", + "version": "0.0.1", + "exports": "./src/index.ts", "imports": { "std/": "https://deno.land/std@0.224.0/" }, diff --git a/docs/improvements.md b/docs/improvements.md index c2069b0..8a559a0 100644 --- a/docs/improvements.md +++ b/docs/improvements.md @@ -131,3 +131,22 @@ Any chosen implementation must: 2. Add new functionality behind feature flags or as opt-in 3. Update documentation with examples 4. Consider creating utilities to help migrate between approaches + +# exports + +Alternative Export Configurations If you want more granular control, you could use an object format +instead: + +```json +"exports": { + ".": "./src/index.ts", + "./types": "./src/core/types/index.ts" +} +``` + +This would allow consumers to import like: + +```ts +import { ... } from "@johanfive/xmas" (main export) +import { ... } from "@johanfive/xmas/types" (types export) +``` diff --git a/src/core/defaults/http-client.ts b/src/core/defaults/http-client.ts index cf88d1c..c41872d 100644 --- a/src/core/defaults/http-client.ts +++ b/src/core/defaults/http-client.ts @@ -1,4 +1,4 @@ -import { HttpClient, HttpRequest, HttpResponse } from '../types/internal/http.ts'; +import type { HttpClient, HttpRequest, HttpResponse } from '../types/internal/http.ts'; export class DefaultHttpClient implements HttpClient { async send(request: HttpRequest): Promise { diff --git a/src/core/defaults/logger.ts b/src/core/defaults/logger.ts index 1707e81..2336521 100644 --- a/src/core/defaults/logger.ts +++ b/src/core/defaults/logger.ts @@ -1,4 +1,4 @@ -import { Logger } from '../types/internal/config.ts'; +import type { Logger } from '../types/internal/config.ts'; export const defaultLogger: Logger = { debug: console.debug.bind(console), diff --git a/src/core/request-builder.test.ts b/src/core/request-builder.test.ts index 5d03009..1b797b2 100644 --- a/src/core/request-builder.test.ts +++ b/src/core/request-builder.test.ts @@ -1,5 +1,5 @@ import { expect } from 'https://deno.land/std@0.224.0/expect/mod.ts'; -import { RequestBuilder, RequestBuildOptions } from './request-builder.ts'; +import { RequestBuilder, type RequestBuildOptions } from './request-builder.ts'; import { XmApiError } from './errors.ts'; // Test helper to create RequestBuilder with standard configuration diff --git a/src/core/request-builder.ts b/src/core/request-builder.ts index 964b77b..2e9e8f4 100644 --- a/src/core/request-builder.ts +++ b/src/core/request-builder.ts @@ -1,4 +1,4 @@ -import { HttpRequest } from './types/internal/http.ts'; +import type { HttpRequest } from './types/internal/http.ts'; import { XmApiError } from './errors.ts'; /** diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index d18df05..c717e53 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -1,18 +1,23 @@ -import { HttpClient, HttpResponse } from './types/internal/http.ts'; +import type { HttpClient, HttpResponse } from './types/internal/http.ts'; import { isAuthCodeConfig, isBasicAuthConfig, isOAuthConfig, - Logger, - TokenRefreshCallback, - XmApiConfig, + type Logger, + type TokenRefreshCallback, + type XmApiConfig, } from './types/internal/config.ts'; import type { MutableAuthState } from './types/internal/auth-state.ts'; -import { DeleteOptions, GetOptions, RequestWithBodyOptions } from './types/internal/methods.ts'; +import type { + DeleteOptions, + GetOptions, + RequestWithBodyOptions, +} from './types/internal/methods.ts'; import { XmApiError } from './errors.ts'; -import { OAuth2TokenResponse } from './types/internal/oauth.ts'; -import { RequestBuilder, RequestBuildOptions } from './request-builder.ts'; +import type { OAuth2TokenResponse } from './types/internal/oauth.ts'; +import { RequestBuilder, type RequestBuildOptions } from './request-builder.ts'; import { DefaultHttpClient, defaultLogger } from './defaults/index.ts'; +import denoJson from '../../deno.json' with { type: 'json' }; export class RequestHandler { /** HTTP client for making requests */ @@ -65,6 +70,7 @@ export class RequestHandler { const headers: Record = { 'Content-Type': 'application/json', 'Accept': 'application/json', + 'User-Agent': `xmas/${denoJson.version} (Deno)`, ...initialConfig.defaultHeaders, }; this.requestBuilder = new RequestBuilder(initialConfig.hostname, headers); diff --git a/src/core/resource-client.ts b/src/core/resource-client.ts index d024168..c1cd4e3 100644 --- a/src/core/resource-client.ts +++ b/src/core/resource-client.ts @@ -3,7 +3,7 @@ import type { GetOptions, RequestWithBodyOptions, } from './types/internal/methods.ts'; -import { RequestHandler } from './request-handler.ts'; +import type { RequestHandler } from './request-handler.ts'; import { XmApiError } from './errors.ts'; /** diff --git a/src/endpoints/groups/index.ts b/src/endpoints/groups/index.ts index 1cfeb16..f3a53b8 100644 --- a/src/endpoints/groups/index.ts +++ b/src/endpoints/groups/index.ts @@ -1,11 +1,11 @@ import { ResourceClient } from '../../core/resource-client.ts'; -import { RequestHandler } from '../../core/request-handler.ts'; +import type { RequestHandler } from '../../core/request-handler.ts'; import type { HttpResponse } from '../../core/types/internal/http.ts'; import type { EmptyHttpResponse, PaginatedHttpResponse, } from '../../core/types/endpoint/response.ts'; -import { GetGroupsParams, GetGroupsResponse, Group } from './types.ts'; +import type { GetGroupsParams, GetGroupsResponse, Group } from './types.ts'; /** * Provides access to the groups endpoints of the xMatters API. diff --git a/src/endpoints/groups/types.ts b/src/endpoints/groups/types.ts index 499bddb..0c78256 100644 --- a/src/endpoints/groups/types.ts +++ b/src/endpoints/groups/types.ts @@ -1,5 +1,5 @@ -import { PaginatedResponse } from '../../core/types/endpoint/response.ts'; -import { WithPagination, WithSearch } from '../../core/types/endpoint/composers.ts'; +import type { PaginatedResponse } from '../../core/types/endpoint/response.ts'; +import type { WithPagination, WithSearch } from '../../core/types/endpoint/composers.ts'; /** * Represents a group in xMatters. diff --git a/src/endpoints/oauth/index.ts b/src/endpoints/oauth/index.ts index d6ce562..b0422c7 100644 --- a/src/endpoints/oauth/index.ts +++ b/src/endpoints/oauth/index.ts @@ -1,6 +1,6 @@ -import { RequestHandler } from '../../core/request-handler.ts'; -import { OAuth2TokenResponse } from '../../core/types/internal/oauth.ts'; -import { HttpResponse } from '../../core/types/internal/http.ts'; +import type { RequestHandler } from '../../core/request-handler.ts'; +import type { OAuth2TokenResponse } from '../../core/types/internal/oauth.ts'; +import type { HttpResponse } from '../../core/types/internal/http.ts'; import { XmApiError } from '../../core/errors.ts'; export class OAuthEndpoint { From d6e3ee9cfd823af4fc867ec5fc201ea5eeace77b Mon Sep 17 00:00:00 2001 From: johan Date: Sun, 15 Jun 2025 14:40:53 -0700 Subject: [PATCH 046/101] Enhance config validation + make std libs imports great again --- src/core/request-builder.test.ts | 2 +- src/core/request-handler.test.ts | 6 +- src/core/resource-client.test.ts | 2 +- src/core/utils/config-validation.test.ts | 448 +++++++++++++++++++++++ src/core/utils/config-validation.ts | 80 +++- src/endpoints/groups/index.test.ts | 6 +- 6 files changed, 522 insertions(+), 22 deletions(-) create mode 100644 src/core/utils/config-validation.test.ts diff --git a/src/core/request-builder.test.ts b/src/core/request-builder.test.ts index 1b797b2..f421576 100644 --- a/src/core/request-builder.test.ts +++ b/src/core/request-builder.test.ts @@ -1,4 +1,4 @@ -import { expect } from 'https://deno.land/std@0.224.0/expect/mod.ts'; +import { expect } from 'std/expect/mod.ts'; import { RequestBuilder, type RequestBuildOptions } from './request-builder.ts'; import { XmApiError } from './errors.ts'; diff --git a/src/core/request-handler.test.ts b/src/core/request-handler.test.ts index 00a735d..af5a8bf 100644 --- a/src/core/request-handler.test.ts +++ b/src/core/request-handler.test.ts @@ -10,9 +10,9 @@ * - Tests both success and error scenarios comprehensively */ -import { expect } from 'https://deno.land/std@0.224.0/expect/mod.ts'; -import { FakeTime } from 'https://deno.land/std@0.224.0/testing/time.ts'; -import { stub } from 'https://deno.land/std@0.224.0/testing/mock.ts'; +import { expect } from 'std/expect/mod.ts'; +import { FakeTime } from 'std/testing/time.ts'; +import { stub } from 'std/testing/mock.ts'; import { RequestHandler } from './request-handler.ts'; import type { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; import type { Logger, XmApiConfig } from './types/internal/config.ts'; diff --git a/src/core/resource-client.test.ts b/src/core/resource-client.test.ts index 02503fe..d9c90f4 100644 --- a/src/core/resource-client.test.ts +++ b/src/core/resource-client.test.ts @@ -1,4 +1,4 @@ -import { expect } from 'https://deno.land/std@0.224.0/expect/mod.ts'; +import { expect } from 'std/expect/mod.ts'; import { ResourceClient } from './resource-client.ts'; import { RequestHandler } from './request-handler.ts'; import { XmApiError } from './errors.ts'; diff --git a/src/core/utils/config-validation.test.ts b/src/core/utils/config-validation.test.ts new file mode 100644 index 0000000..a6eaafb --- /dev/null +++ b/src/core/utils/config-validation.test.ts @@ -0,0 +1,448 @@ +import { expect } from 'std/expect/mod.ts'; +import { validateConfig } from './config-validation.ts'; +import { XmApiError } from '../errors.ts'; + +Deno.test('validateConfig - null/undefined config', () => { + // @ts-ignore - Testing invalid input types + expect(() => validateConfig(null)).toThrow(XmApiError); + // @ts-ignore - Testing invalid input types + expect(() => validateConfig(null)).toThrow('Configuration object is required'); + + // @ts-ignore - Testing invalid input types + expect(() => validateConfig(undefined)).toThrow(XmApiError); + // @ts-ignore - Testing invalid input types + expect(() => validateConfig(undefined)).toThrow('Configuration object is required'); +}); + +Deno.test('validateConfig - non-object config', () => { + // @ts-ignore - Testing invalid input types + expect(() => validateConfig('string')).toThrow(XmApiError); + // @ts-ignore - Testing invalid input types + expect(() => validateConfig('string')).toThrow('Expected object'); + + // @ts-ignore - Testing invalid input types + expect(() => validateConfig(123)).toThrow(XmApiError); + // @ts-ignore - Testing invalid input types + expect(() => validateConfig(123)).toThrow('Expected object'); + + // @ts-ignore - Testing invalid input types + expect(() => validateConfig(true)).toThrow(XmApiError); + // @ts-ignore - Testing invalid input types + expect(() => validateConfig(true)).toThrow('Expected object'); +}); + +Deno.test('validateConfig - array config', () => { + // @ts-ignore - Testing invalid input types + expect(() => validateConfig([])).toThrow(XmApiError); + // @ts-ignore - Testing invalid input types + expect(() => validateConfig([])).toThrow('Expected object'); + + // @ts-ignore - Testing invalid input types + expect(() => validateConfig(['test'])).toThrow(XmApiError); + // @ts-ignore - Testing invalid input types + expect(() => validateConfig(['test'])).toThrow('Expected object'); +}); + +Deno.test('validateConfig - invalid hostname', () => { + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ hostname: 123 })).toThrow(XmApiError); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ hostname: 123 })).toThrow('hostname must be a string'); + + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ hostname: null })).toThrow(XmApiError); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ hostname: null })).toThrow('hostname must be a string'); + + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ hostname: undefined })).toThrow(XmApiError); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ hostname: undefined })).toThrow('hostname must be a string'); +}); + +Deno.test('validateConfig - invalid maxRetries', () => { + const baseConfig = { hostname: 'test.com', username: 'user', password: 'pass' }; + + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, maxRetries: 'invalid' })).toThrow(XmApiError); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, maxRetries: 'invalid' })).toThrow( + 'maxRetries must be a non-negative integer', + ); + + expect(() => validateConfig({ ...baseConfig, maxRetries: -1 })).toThrow(XmApiError); + expect(() => validateConfig({ ...baseConfig, maxRetries: -1 })).toThrow( + 'maxRetries must be a non-negative integer', + ); + + expect(() => validateConfig({ ...baseConfig, maxRetries: 1.5 })).toThrow(XmApiError); + expect(() => validateConfig({ ...baseConfig, maxRetries: 1.5 })).toThrow( + 'maxRetries must be a non-negative integer', + ); +}); + +Deno.test('validateConfig - valid maxRetries', () => { + const baseConfig = { hostname: 'test.com', username: 'user', password: 'pass' }; + + expect(() => validateConfig({ ...baseConfig, maxRetries: 0 })).not.toThrow(); + expect(() => validateConfig({ ...baseConfig, maxRetries: 3 })).not.toThrow(); + expect(() => validateConfig({ ...baseConfig, maxRetries: 10 })).not.toThrow(); +}); + +Deno.test('validateConfig - no auth method provided', () => { + // @ts-ignore - Testing incomplete config + const config = { hostname: 'test.com' }; + + // @ts-ignore - Testing incomplete config + expect(() => validateConfig(config)).toThrow(XmApiError); + // @ts-ignore - Testing incomplete config + expect(() => validateConfig(config)).toThrow( + 'Must provide either basic auth credentials, authorization code, or OAuth tokens', + ); +}); + +Deno.test('validateConfig - multiple auth methods', () => { + // @ts-ignore - Testing invalid config combination + const config = { + hostname: 'test.com', + username: 'user', + password: 'pass', + authorizationCode: 'code', + clientId: 'client', + }; + + expect(() => validateConfig(config)).toThrow(XmApiError); + expect(() => validateConfig(config)).toThrow( + 'Cannot mix basic auth, authorization code, and OAuth token fields', + ); +}); + +Deno.test('validateConfig - basic auth validation', () => { + const baseConfig = { hostname: 'test.com' }; + + // Invalid username types + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, username: 123, password: 'pass' })).toThrow( + XmApiError, + ); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, username: 123, password: 'pass' })).toThrow( + 'username must be a non-empty string', + ); + + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, username: null, password: 'pass' })).toThrow( + XmApiError, + ); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, username: null, password: 'pass' })).toThrow( + 'username must be a non-empty string', + ); + + // Empty username + expect(() => validateConfig({ ...baseConfig, username: '', password: 'pass' })).toThrow( + XmApiError, + ); + expect(() => validateConfig({ ...baseConfig, username: '', password: 'pass' })).toThrow( + 'username must be a non-empty string', + ); + + // Invalid password types + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, username: 'user', password: 123 })).toThrow( + XmApiError, + ); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, username: 'user', password: 123 })).toThrow( + 'password must be a non-empty string', + ); + + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, username: 'user', password: null })).toThrow( + XmApiError, + ); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, username: 'user', password: null })).toThrow( + 'password must be a non-empty string', + ); + + // Empty password + expect(() => validateConfig({ ...baseConfig, username: 'user', password: '' })).toThrow( + XmApiError, + ); + expect(() => validateConfig({ ...baseConfig, username: 'user', password: '' })).toThrow( + 'password must be a non-empty string', + ); +}); + +Deno.test('validateConfig - valid basic auth', () => { + const config = { + hostname: 'test.com', + username: 'user', + password: 'pass', + }; + + expect(() => validateConfig(config)).not.toThrow(); +}); + +Deno.test('validateConfig - auth code validation', () => { + const baseConfig = { hostname: 'test.com' }; + + // Invalid authorizationCode types + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, authorizationCode: 123, clientId: 'client' })) + .toThrow(XmApiError); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, authorizationCode: 123, clientId: 'client' })) + .toThrow('authorizationCode must be a non-empty string'); + + // Empty authorizationCode + expect(() => validateConfig({ ...baseConfig, authorizationCode: '', clientId: 'client' })) + .toThrow(XmApiError); + expect(() => validateConfig({ ...baseConfig, authorizationCode: '', clientId: 'client' })) + .toThrow('authorizationCode must be a non-empty string'); + + // Missing clientId + // @ts-ignore - Testing incomplete config + expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code' })).toThrow(XmApiError); + // @ts-ignore - Testing incomplete config + expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code' })).toThrow( + 'clientId must be a non-empty string', + ); + + // Invalid clientId types + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code', clientId: 123 })).toThrow( + XmApiError, + ); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code', clientId: 123 })).toThrow( + 'clientId must be a non-empty string', + ); + + // Empty clientId + expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code', clientId: '' })).toThrow( + XmApiError, + ); + expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code', clientId: '' })).toThrow( + 'clientId must be a non-empty string', + ); + + // Invalid clientSecret type (when provided) + // @ts-ignore - Testing invalid property types + expect(() => + validateConfig({ + ...baseConfig, + authorizationCode: 'code', + clientId: 'client', + // @ts-ignore - Testing invalid property types + clientSecret: 123, + }) + ).toThrow(XmApiError); + // @ts-ignore - Testing invalid property types + expect(() => + validateConfig({ + ...baseConfig, + authorizationCode: 'code', + clientId: 'client', + // @ts-ignore - Testing invalid property types + clientSecret: 123, + }) + ).toThrow('clientSecret must be a string'); +}); + +Deno.test('validateConfig - valid auth code', () => { + const config = { + hostname: 'test.com', + authorizationCode: 'code', + clientId: 'client', + }; + + expect(() => validateConfig(config)).not.toThrow(); + + // With optional clientSecret + const configWithSecret = { + ...config, + clientSecret: 'secret', + }; + + expect(() => validateConfig(configWithSecret)).not.toThrow(); +}); + +Deno.test('validateConfig - OAuth tokens validation', () => { + const baseConfig = { hostname: 'test.com' }; + + // Invalid accessToken types + // @ts-ignore - Testing invalid property types + expect(() => + validateConfig({ + ...baseConfig, + // @ts-ignore - Testing invalid property types + accessToken: 123, + refreshToken: 'refresh', + clientId: 'client', + }) + ).toThrow(XmApiError); + // @ts-ignore - Testing invalid property types + expect(() => + validateConfig({ + ...baseConfig, + // @ts-ignore - Testing invalid property types + accessToken: 123, + refreshToken: 'refresh', + clientId: 'client', + }) + ).toThrow('accessToken must be a non-empty string'); + + // Empty accessToken + expect(() => + validateConfig({ ...baseConfig, accessToken: '', refreshToken: 'refresh', clientId: 'client' }) + ).toThrow(XmApiError); + expect(() => + validateConfig({ ...baseConfig, accessToken: '', refreshToken: 'refresh', clientId: 'client' }) + ).toThrow('accessToken must be a non-empty string'); + + // Invalid refreshToken types + // @ts-ignore - Testing invalid property types + expect(() => + validateConfig({ + ...baseConfig, + accessToken: 'access', + // @ts-ignore - Testing invalid property types + refreshToken: 123, + clientId: 'client', + }) + ).toThrow(XmApiError); + // @ts-ignore - Testing invalid property types + expect(() => + validateConfig({ + ...baseConfig, + accessToken: 'access', + // @ts-ignore - Testing invalid property types + refreshToken: 123, + clientId: 'client', + }) + ).toThrow('refreshToken must be a non-empty string'); + + // Empty refreshToken + expect(() => + validateConfig({ ...baseConfig, accessToken: 'access', refreshToken: '', clientId: 'client' }) + ).toThrow(XmApiError); + expect(() => + validateConfig({ ...baseConfig, accessToken: 'access', refreshToken: '', clientId: 'client' }) + ).toThrow('refreshToken must be a non-empty string'); + + // Missing clientId + // @ts-ignore - Testing incomplete config + expect(() => validateConfig({ ...baseConfig, accessToken: 'access', refreshToken: 'refresh' })) + .toThrow(XmApiError); + // @ts-ignore - Testing incomplete config + expect(() => validateConfig({ ...baseConfig, accessToken: 'access', refreshToken: 'refresh' })) + .toThrow('clientId must be a non-empty string'); + + // Invalid clientId types + // @ts-ignore - Testing invalid property types + expect(() => + validateConfig({ + ...baseConfig, + accessToken: 'access', + refreshToken: 'refresh', + // @ts-ignore - Testing invalid property types + clientId: 123, + }) + ).toThrow(XmApiError); + // @ts-ignore - Testing invalid property types + expect(() => + validateConfig({ + ...baseConfig, + accessToken: 'access', + refreshToken: 'refresh', + // @ts-ignore - Testing invalid property types + clientId: 123, + }) + ).toThrow('clientId must be a non-empty string'); + + // Empty clientId + expect(() => + validateConfig({ ...baseConfig, accessToken: 'access', refreshToken: 'refresh', clientId: '' }) + ).toThrow(XmApiError); + expect(() => + validateConfig({ ...baseConfig, accessToken: 'access', refreshToken: 'refresh', clientId: '' }) + ).toThrow('clientId must be a non-empty string'); + + // Invalid expiresAt type (when provided) + // @ts-ignore - Testing invalid property types + expect(() => + validateConfig({ + ...baseConfig, + accessToken: 'access', + refreshToken: 'refresh', + clientId: 'client', + // @ts-ignore - Testing invalid property types + expiresAt: 123, + }) + ).toThrow(XmApiError); + // @ts-ignore - Testing invalid property types + expect(() => + validateConfig({ + ...baseConfig, + accessToken: 'access', + refreshToken: 'refresh', + clientId: 'client', + // @ts-ignore - Testing invalid property types + expiresAt: 123, + }) + ).toThrow('expiresAt must be a string'); +}); + +Deno.test('validateConfig - valid OAuth tokens', () => { + const config = { + hostname: 'test.com', + accessToken: 'access', + refreshToken: 'refresh', + clientId: 'client', + }; + + expect(() => validateConfig(config)).not.toThrow(); + + // With optional expiresAt + const configWithExpiry = { + ...config, + expiresAt: '2025-12-31T23:59:59Z', + }; + + expect(() => validateConfig(configWithExpiry)).not.toThrow(); +}); + +Deno.test('validateConfig - edge cases', () => { + // maxRetries undefined should be fine + const config = { + hostname: 'test.com', + username: 'user', + password: 'pass', + maxRetries: undefined, + }; + + expect(() => validateConfig(config)).not.toThrow(); + + // clientSecret undefined should be fine + const authCodeConfig = { + hostname: 'test.com', + authorizationCode: 'code', + clientId: 'client', + clientSecret: undefined, + }; + + expect(() => validateConfig(authCodeConfig)).not.toThrow(); + + // expiresAt undefined should be fine + const oauthConfig = { + hostname: 'test.com', + accessToken: 'access', + refreshToken: 'refresh', + clientId: 'client', + expiresAt: undefined, + }; + + expect(() => validateConfig(oauthConfig)).not.toThrow(); +}); diff --git a/src/core/utils/config-validation.ts b/src/core/utils/config-validation.ts index 6390c96..c473257 100644 --- a/src/core/utils/config-validation.ts +++ b/src/core/utils/config-validation.ts @@ -3,15 +3,41 @@ import type { XmApiConfig } from '../types/internal/config.ts'; /** * Validates that the config is in exactly one valid state. - * Prevents invalid overlapping configurations. + * Prevents invalid overlapping configurations and validates data types. */ export function validateConfig(config: XmApiConfig): void { + // 1. Basic existence check + if (config === null || config === undefined) { + throw new XmApiError('Invalid config: Configuration object is required'); + } + + if (typeof config !== 'object' || Array.isArray(config)) { + throw new XmApiError('Invalid config: Expected object'); + } + + // 2. Validate hostname + if (typeof config.hostname !== 'string') { + throw new XmApiError('Invalid config: hostname must be a string'); + } + + // 3. Validate maxRetries if provided + if (config.maxRetries !== undefined) { + if ( + typeof config.maxRetries !== 'number' || config.maxRetries < 0 || + !Number.isInteger(config.maxRetries) + ) { + throw new XmApiError('Invalid config: maxRetries must be a non-negative integer'); + } + } + + // 4. Determine which auth methods are present const hasBasicAuth = 'username' in config && 'password' in config; const hasAuthCode = 'authorizationCode' in config; const hasOAuthTokens = 'accessToken' in config && 'refreshToken' in config; const configCount = [hasBasicAuth, hasAuthCode, hasOAuthTokens].filter(Boolean).length; + // 5. Validate exactly one auth method is provided if (configCount === 0) { throw new XmApiError( 'Invalid config: Must provide either basic auth credentials, authorization code, or OAuth tokens', @@ -24,22 +50,48 @@ export function validateConfig(config: XmApiConfig): void { ); } - // Validate required fields for each config type - if (hasBasicAuth && (!config.username || !config.password)) { - throw new XmApiError('Invalid config: Basic auth requires both username and password'); - } - - if (hasAuthCode && !config.authorizationCode) { - throw new XmApiError('Invalid config: Auth code flow requires authorizationCode'); + // 6. Validate required fields and types for each config type + if (hasBasicAuth) { + if (typeof config.username !== 'string' || !config.username) { + throw new XmApiError('Invalid config: username must be a non-empty string'); + } + if (typeof config.password !== 'string' || !config.password) { + throw new XmApiError('Invalid config: password must be a non-empty string'); + } } - if (hasAuthCode && !config.clientId) { - throw new XmApiError('Invalid config: Auth code flow requires clientId'); + if (hasAuthCode) { + if (typeof config.authorizationCode !== 'string' || !config.authorizationCode) { + throw new XmApiError('Invalid config: authorizationCode must be a non-empty string'); + } + if (!('clientId' in config) || typeof config.clientId !== 'string' || !config.clientId) { + throw new XmApiError('Invalid config: clientId must be a non-empty string'); + } + // Validate optional clientSecret if provided + if ( + 'clientSecret' in config && config.clientSecret !== undefined && + typeof config.clientSecret !== 'string' + ) { + throw new XmApiError('Invalid config: clientSecret must be a string'); + } } - if (hasOAuthTokens && (!config.accessToken || !config.refreshToken || !config.clientId)) { - throw new XmApiError( - 'Invalid config: OAuth config requires accessToken, refreshToken, and clientId', - ); + if (hasOAuthTokens) { + if (typeof config.accessToken !== 'string' || !config.accessToken) { + throw new XmApiError('Invalid config: accessToken must be a non-empty string'); + } + if (typeof config.refreshToken !== 'string' || !config.refreshToken) { + throw new XmApiError('Invalid config: refreshToken must be a non-empty string'); + } + if (!('clientId' in config) || typeof config.clientId !== 'string' || !config.clientId) { + throw new XmApiError('Invalid config: clientId must be a non-empty string'); + } + // Validate optional expiresAt if provided + if ( + 'expiresAt' in config && config.expiresAt !== undefined && + typeof config.expiresAt !== 'string' + ) { + throw new XmApiError('Invalid config: expiresAt must be a string'); + } } } diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index 84e8d89..44262c7 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -18,9 +18,9 @@ * - Easy maintenance as it focuses on the interface contract */ -import { expect } from 'https://deno.land/std@0.224.0/expect/mod.ts'; -import { stub } from 'https://deno.land/std@0.224.0/testing/mock.ts'; -import { FakeTime } from 'https://deno.land/std@0.224.0/testing/time.ts'; +import { expect } from 'std/expect/mod.ts'; +import { stub } from 'std/testing/mock.ts'; +import { FakeTime } from 'std/testing/time.ts'; import { GroupsEndpoint } from './index.ts'; import { RequestHandler } from '../../core/request-handler.ts'; From 4f218c5f7a15c23be88c8bcfcadf17d53af2cac5 Mon Sep 17 00:00:00 2001 From: johan Date: Sun, 15 Jun 2025 23:44:31 -0700 Subject: [PATCH 047/101] No more explicitly returning undefined --- src/core/errors.ts | 2 +- src/core/request-handler.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/errors.ts b/src/core/errors.ts index b30bb79..368b561 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -27,7 +27,7 @@ export class XmApiError extends Error { status: number; /** Response headers that may contain additional error context */ headers: Record; - }, + } | null, public override readonly cause?: unknown, ) { // Use custom message if provided and meaningful, otherwise extract from response diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index c717e53..90b5da3 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -146,14 +146,14 @@ export class RequestHandler { private createAuthHeader(): string | undefined { if (this.mutableAuthState.type === 'oauth') { return `Bearer ${this.mutableAuthState.accessToken}`; - } else if (this.mutableAuthState.type === 'basic') { + } + if (this.mutableAuthState.type === 'basic') { // In Deno, we use TextEncoder for proper UTF-8 encoding const encoder = new TextEncoder(); const authString = `${this.mutableAuthState.username}:${this.mutableAuthState.password}`; const auth = btoa(String.fromCharCode(...encoder.encode(authString))); return `Basic ${auth}`; } - return undefined; } async send( @@ -225,7 +225,7 @@ export class RequestHandler { if (error instanceof XmApiError) { throw error; } - throw new XmApiError('Request failed', undefined, error); + throw new XmApiError('Request failed', null, error); } } From 8933eaaa64227429838026382bfe2c05ec5e4ad3 Mon Sep 17 00:00:00 2001 From: johan Date: Mon, 16 Jun 2025 22:13:48 -0700 Subject: [PATCH 048/101] Make extractErrorMessage slightly more resilient + minor cosmetic changes --- sandbox/.env.example | 2 + sandbox/config.ts | 6 +- src/core/errors.ts | 4 +- src/core/request-builder.ts | 19 +--- src/core/request-handler.test.ts | 6 +- src/core/request-handler.ts | 165 +++++++++++++++-------------- src/endpoints/groups/index.test.ts | 6 +- src/index.ts | 32 ------ 8 files changed, 101 insertions(+), 139 deletions(-) diff --git a/sandbox/.env.example b/sandbox/.env.example index be4f863..25f836b 100644 --- a/sandbox/.env.example +++ b/sandbox/.env.example @@ -4,3 +4,5 @@ USERNAME='your-username' PASSWORD='your-password' # for OAuth CLIENT_ID='your-client-id' +EXPIRED_ACCESS_TOKEN='any-expired-access-token' +REFRESH_TOKEN='any-refresh-token' \ No newline at end of file diff --git a/sandbox/config.ts b/sandbox/config.ts index 2ee4f05..92ff55e 100644 --- a/sandbox/config.ts +++ b/sandbox/config.ts @@ -3,6 +3,8 @@ const { USERNAME, PASSWORD, CLIENT_ID, + EXPIRED_ACCESS_TOKEN, + REFRESH_TOKEN, } = Deno.env.toObject(); // Various configuration options to initiate the SDK with @@ -23,8 +25,8 @@ const oauth = { byRefreshToken: { hostname: HOSTNAME, clientId: CLIENT_ID, - accessToken: 'TODO', - refreshToken: 'TODO', + accessToken: EXPIRED_ACCESS_TOKEN, + refreshToken: REFRESH_TOKEN, }, }; diff --git a/src/core/errors.ts b/src/core/errors.ts index 368b561..cb59b8c 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -55,7 +55,7 @@ export class XmApiError extends Error { status: number; }): string { // Default fallback message - const defaultMessage = `Request failed with status ${response.status}`; + const defaultMessage = `DEBUG: Request failed with status ${response.status}`; // If no response body, use default if (!response.body) { @@ -69,7 +69,7 @@ export class XmApiError extends Error { } // If response body is not an object, use default - if (typeof response.body !== 'object') { + if (typeof response.body !== 'object' || Array.isArray(response.body)) { return defaultMessage; } diff --git a/src/core/request-builder.ts b/src/core/request-builder.ts index 2e9e8f4..675f7a0 100644 --- a/src/core/request-builder.ts +++ b/src/core/request-builder.ts @@ -13,29 +13,23 @@ export interface RequestBuildOptions { * @example "/people" */ path?: string; - /** - * A complete URL to an external endpoint. - * Use this when you need to bypass the xMatters API completely. + * A fully qualified URL. + * Use to bypass URL building logic entirely. * @example "https://api.external-service.com/v2/endpoint" + * @example "https://you.xmatters.com/api/integration/1/functions/6358eaf3-6213-42fc-8629-e823cf5739cb/triggers?apiKey=a12bcde3-456f-7g89-123a-b456789cd000" */ fullUrl?: string; - /** The HTTP method to use for the request */ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; - /** Optional headers to send with the request */ headers?: Record; - /** Optional query parameters to include in the URL */ query?: Record; - /** Optional request body */ body?: unknown; - /** Used internally for retry logic */ retryAttempt?: number; - /** Whether to skip adding authentication headers to this request */ skipAuth?: boolean; } @@ -50,13 +44,11 @@ export class RequestBuilder { build(options: RequestBuildOptions): HttpRequest { let url: URL; - if (options.fullUrl && options.path) { throw new XmApiError( 'Cannot specify both fullUrl and path. Use fullUrl for external endpoints, path for xMatters API endpoints.', ); } - if (options.fullUrl) { url = new URL(options.fullUrl); } else if (options.path) { @@ -67,8 +59,6 @@ export class RequestBuilder { } else { throw new XmApiError('Either path or fullUrl must be provided'); } - - // Add query parameters if present in the options if (options.query) { Object.entries(options.query).forEach(([key, value]) => { if (value !== undefined && value !== null) { @@ -76,10 +66,8 @@ export class RequestBuilder { } }); } - // Build headers by merging default headers with request-specific headers const headers: Record = { ...this.defaultHeaders, ...options.headers }; - const builtRequest: HttpRequest = { method: options.method || 'GET', url: url.toString(), @@ -87,7 +75,6 @@ export class RequestBuilder { body: options.body, retryAttempt: options.retryAttempt || 0, }; - return builtRequest; } } diff --git a/src/core/request-handler.test.ts b/src/core/request-handler.test.ts index af5a8bf..aa65e99 100644 --- a/src/core/request-handler.test.ts +++ b/src/core/request-handler.test.ts @@ -445,7 +445,7 @@ Deno.test('RequestHandler', async (t) => { // Should be: initial request log + retry message + retry request log = 3 calls expect(debugStub.calls.length).toBe(3); expect(debugStub.calls[1].args[0]).toBe( - 'Request failed with status 429, retrying in 1000ms (attempt 1/3)', + 'DEBUG: Request failed with status 429, retrying in 1000ms (attempt 1/3)', ); } finally { debugStub.restore(); @@ -610,7 +610,7 @@ Deno.test('RequestHandler', async (t) => { // Should be: initial request log + retry message + retry request log = 3 calls expect(debugStub.calls.length).toBe(3); expect(debugStub.calls[1].args[0]).toBe( - 'Request failed with status 503, retrying in 1000ms (attempt 1/3)', + 'DEBUG: Request failed with status 503, retrying in 1000ms (attempt 1/3)', ); } finally { debugStub.restore(); @@ -657,7 +657,7 @@ Deno.test('RequestHandler', async (t) => { // Should be: initial request log + retry message + retry request log = 3 calls expect(debugStub.calls.length).toBe(3); expect(debugStub.calls[1].args[0]).toBe( - 'Request failed with status 429, retrying in 5000ms (attempt 1/3)', + 'DEBUG: Request failed with status 429, retrying in 5000ms (attempt 1/3)', ); } finally { debugStub.restore(); diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index 90b5da3..fb4107f 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -76,86 +76,6 @@ export class RequestHandler { this.requestBuilder = new RequestBuilder(initialConfig.hostname, headers); } - /** - * Execute the onTokenRefresh callback if provided (with error handling) - */ - private async executeTokenRefreshCallback( - accessToken: string, - refreshToken: string, - ): Promise { - if (this.onTokenRefresh) { - try { - await this.onTokenRefresh(accessToken, refreshToken); - } catch (error) { - this.logger.warn( - 'Error in onTokenRefresh callback, but continuing with refreshed token', - error, - ); - } - } - } - - private isTokenExpired(): boolean { - if (this.mutableAuthState.type !== 'oauth') return false; - // If there's no expiration info, assume it's valid - if (!this.mutableAuthState.expiresAt) return false; - const expiresAt = new Date(this.mutableAuthState.expiresAt); - // Consider token expired if it expires in less than 30 seconds - return expiresAt.getTime() - Date.now() <= 30 * 1000; - } - - private async refreshToken(): Promise { - try { - if (this.mutableAuthState.type !== 'oauth') { - throw new XmApiError('No OAuth configuration available for token refresh'); - } - const params = new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: this.mutableAuthState.refreshToken, - client_id: this.mutableAuthState.clientId, - }); - const refreshRequest = this.requestBuilder.build({ - method: 'POST', - path: '/oauth2/token', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - }, - body: params.toString(), - }); - const response = await this.client.send(refreshRequest); - if (response.status < 200 || response.status >= 300) { - throw new XmApiError('Failed to refresh token', response); - } - const tokenResponse = response.body as OAuth2TokenResponse; - await this.handleNewOAuthTokens(tokenResponse, this.mutableAuthState.clientId); - } catch (error) { - this.logger.error('Failed to refresh token:', error); - throw error; - } - } - - private exponentialBackoff(attempt: number): number { - // Calculate delay with exponential backoff: 1s, 2s, 4s, 8s, capped at 10s - return Math.min(1000 * Math.pow(2, attempt), 10000); - } - - /** - * Creates the authorization header value based on the authentication type - */ - private createAuthHeader(): string | undefined { - if (this.mutableAuthState.type === 'oauth') { - return `Bearer ${this.mutableAuthState.accessToken}`; - } - if (this.mutableAuthState.type === 'basic') { - // In Deno, we use TextEncoder for proper UTF-8 encoding - const encoder = new TextEncoder(); - const authString = `${this.mutableAuthState.username}:${this.mutableAuthState.password}`; - const auth = btoa(String.fromCharCode(...encoder.encode(authString))); - return `Basic ${auth}`; - } - } - async send( request: RequestBuildOptions, ): Promise> { @@ -208,7 +128,7 @@ export class RequestHandler { } } this.logger.debug( - `Request failed with status ${response.status}, retrying in ${finalDelay}ms (attempt ${ + `DEBUG: Request failed with status ${response.status}, retrying in ${finalDelay}ms (attempt ${ currentAttempt + 1 }/${this.maxRetries})`, ); @@ -249,6 +169,56 @@ export class RequestHandler { return this.send({ ...options, method: 'DELETE' }); } + /** + * Creates the authorization header value based on the authentication type + */ + private createAuthHeader(): string | undefined { + if (this.mutableAuthState.type === 'oauth') { + return `Bearer ${this.mutableAuthState.accessToken}`; + } + if (this.mutableAuthState.type === 'basic') { + // In Deno, we use TextEncoder for proper UTF-8 encoding + const encoder = new TextEncoder(); + const authString = `${this.mutableAuthState.username}:${this.mutableAuthState.password}`; + const auth = btoa(String.fromCharCode(...encoder.encode(authString))); + return `Basic ${auth}`; + } + } + + private async refreshToken(): Promise { + try { + if (this.mutableAuthState.type !== 'oauth') { + throw new XmApiError('No OAuth configuration available for token refresh'); + } + const params = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: this.mutableAuthState.refreshToken, + client_id: this.mutableAuthState.clientId, + }); + const refreshRequest = this.requestBuilder.build({ + method: 'POST', + path: '/oauth2/token', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }, + body: params.toString(), + }); + this.logger.debug( + `DEBUG: Refreshing token for client ${this.mutableAuthState.clientId}`, + ); + const response = await this.client.send(refreshRequest); + if (response.status < 200 || response.status >= 300) { + throw new XmApiError('Failed to refresh token', response); + } + const tokenResponse = response.body as OAuth2TokenResponse; + await this.handleNewOAuthTokens(tokenResponse, this.mutableAuthState.clientId); + } catch (error) { + this.logger.error('Failed to refresh token:', error); + throw error; + } + } + /** * Handles newly acquired or refreshed OAuth tokens. * This method processes token responses from any source and updates the authentication state: @@ -269,6 +239,39 @@ export class RequestHandler { await this.executeTokenRefreshCallback(tokenResponse.access_token, tokenResponse.refresh_token); } + /** + * Execute the onTokenRefresh callback if provided (with error handling) + */ + private async executeTokenRefreshCallback( + accessToken: string, + refreshToken: string, + ): Promise { + if (this.onTokenRefresh) { + try { + await this.onTokenRefresh(accessToken, refreshToken); + } catch (error) { + this.logger.warn( + 'Error in onTokenRefresh callback, but continuing with refreshed token', + error, + ); + } + } + } + + private isTokenExpired(): boolean { + if (this.mutableAuthState.type !== 'oauth') return false; + // If there's no expiration info, assume it's valid + if (!this.mutableAuthState.expiresAt) return false; + const expiresAt = new Date(this.mutableAuthState.expiresAt); + // Consider token expired if it expires in less than 30 seconds + return expiresAt.getTime() - Date.now() <= 30 * 1000; + } + + private exponentialBackoff(attempt: number): number { + // Calculate delay with exponential backoff: 1s, 2s, 4s, 8s, capped at 10s + return Math.min(1000 * Math.pow(2, attempt), 10000); + } + /** * Gets the current mutable authentication state. * Callers can use the `type` property to determine the authentication method diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index 44262c7..a15f470 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -495,7 +495,7 @@ Deno.test('GroupsEndpoint', async (t) => { // Should be: initial request log + retry message + retry request log = 3 calls expect(debugStub.calls.length).toBe(3); expect(debugStub.calls[1].args[0]).toBe( - 'Request failed with status 429, retrying in 2000ms (attempt 1/3)', + 'DEBUG: Request failed with status 429, retrying in 2000ms (attempt 1/3)', ); } finally { sendStub.restore(); @@ -557,7 +557,7 @@ Deno.test('GroupsEndpoint', async (t) => { // Should be: initial request log + retry message + retry request log = 3 calls expect(debugStub.calls.length).toBe(3); expect(debugStub.calls[1].args[0]).toBe( - 'Request failed with status 503, retrying in 1000ms (attempt 1/3)', + 'DEBUG: Request failed with status 503, retrying in 1000ms (attempt 1/3)', ); } finally { sendStub.restore(); @@ -700,7 +700,7 @@ Deno.test('GroupsEndpoint', async (t) => { expect(sendStub.calls.length).toBe(1); expect(thrownError).toBeInstanceOf(XmApiError); const xmError = thrownError as XmApiError; - expect(xmError.message).toBe('Request failed with status 400'); + expect(xmError.message).toBe('DEBUG: Request failed with status 400'); expect(xmError.response?.status).toBe(400); expect(xmError.response?.body).toBe(''); } finally { diff --git a/src/index.ts b/src/index.ts index 6e34c32..d01ee72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,38 +7,6 @@ import { OAuthEndpoint } from './endpoints/oauth/index.ts'; /** * Main entry point for the xMatters API client. * This class provides access to all API endpoints through its properties. - * - * @example Basic Authentication - * ```typescript - * const xm = new XmApi({ - * hostname: 'https://example.xmatters.com', - * username: 'your-username', - * password: 'your-password', - * // Optional configurations - * httpClient: myCustomHttpClient, - * logger: myCustomLogger, - * defaultHeaders: { 'Custom-Header': 'value' }, - * maxRetries: 3, - * }); - * ``` - * - * @example OAuth Authentication (with existing tokens) - * ```typescript - * const xm = new XmApi({ - * hostname: 'https://example.xmatters.com', - * accessToken: 'your-access-token', - * refreshToken: 'your-refresh-token', - * clientId: 'your-client-id', - * // Optional configurations - * httpClient: myCustomHttpClient, - * logger: myCustomLogger, - * defaultHeaders: { 'Custom-Header': 'value' }, - * maxRetries: 3, - * onTokenRefresh: (accessToken, refreshToken) => { - * // Store the new tokens - * }, - * }); - * ``` */ export class XmApi { /** HTTP handler that manages all API requests */ From d2c86d3321cd3f5d0e0eeb6b7e81f004be85aff7 Mon Sep 17 00:00:00 2001 From: johan Date: Mon, 16 Jun 2025 23:10:49 -0700 Subject: [PATCH 049/101] Revisit token expiration logic --- src/core/request-handler.test.ts | 2 +- src/core/request-handler.ts | 20 +++++++++++++------- src/core/types/internal/auth-state.ts | 3 ++- src/core/types/internal/config.ts | 1 - src/endpoints/groups/index.test.ts | 2 +- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/core/request-handler.test.ts b/src/core/request-handler.test.ts index aa65e99..5af4fcf 100644 --- a/src/core/request-handler.test.ts +++ b/src/core/request-handler.test.ts @@ -316,7 +316,7 @@ Deno.test('RequestHandler', async (t) => { expect(thrownError).toBeInstanceOf(XmApiError); const xmError = thrownError as XmApiError; expect(xmError.message).toBe('Request failed'); - expect(xmError.response).toBeUndefined(); + expect(xmError.response).toBeNull(); expect(xmError.cause).toBeInstanceOf(Error); expect((xmError.cause as Error).message).toBe('Network error'); } finally { diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index fb4107f..1a0c928 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -54,7 +54,6 @@ export class RequestHandler { accessToken: initialConfig.accessToken, refreshToken: initialConfig.refreshToken, clientId: initialConfig.clientId, - expiresAt: initialConfig.expiresAt, }; } else if (isAuthCodeConfig(initialConfig)) { this.mutableAuthState = { @@ -234,7 +233,8 @@ export class RequestHandler { accessToken: tokenResponse.access_token, refreshToken: tokenResponse.refresh_token, clientId: clientId, - expiresAt: new Date(Date.now() + (tokenResponse.expires_in * 1000)).toISOString(), + expiresInSeconds: tokenResponse.expires_in, + tokenIssuedAtMs: Date.now(), }; await this.executeTokenRefreshCallback(tokenResponse.access_token, tokenResponse.refresh_token); } @@ -260,11 +260,17 @@ export class RequestHandler { private isTokenExpired(): boolean { if (this.mutableAuthState.type !== 'oauth') return false; - // If there's no expiration info, assume it's valid - if (!this.mutableAuthState.expiresAt) return false; - const expiresAt = new Date(this.mutableAuthState.expiresAt); - // Consider token expired if it expires in less than 30 seconds - return expiresAt.getTime() - Date.now() <= 30 * 1000; + // If we don't have expiration info, assume it's valid + // since consumers likely cache tokens and we don't want to + // prematurely refresh tokens that are probably still good. + if (!this.mutableAuthState.expiresInSeconds || !this.mutableAuthState.tokenIssuedAtMs) { + return false; + } + // Calculate how long the token has been alive (in seconds) + const tokenElapsedSeconds = (Date.now() - this.mutableAuthState.tokenIssuedAtMs) / 1000; + // Consider token expired if it's within the buffer period of expiry + const bufferSeconds = 30; + return tokenElapsedSeconds >= (this.mutableAuthState.expiresInSeconds - bufferSeconds); } private exponentialBackoff(attempt: number): number { diff --git a/src/core/types/internal/auth-state.ts b/src/core/types/internal/auth-state.ts index aa7555f..26d76c7 100644 --- a/src/core/types/internal/auth-state.ts +++ b/src/core/types/internal/auth-state.ts @@ -15,5 +15,6 @@ export type MutableAuthState = accessToken: string; refreshToken: string; clientId: string; - expiresAt?: string; + expiresInSeconds?: number; // Original seconds from API response + tokenIssuedAtMs?: number; // Date.now() when token was received }; diff --git a/src/core/types/internal/config.ts b/src/core/types/internal/config.ts index 9a9aac0..a7050ff 100644 --- a/src/core/types/internal/config.ts +++ b/src/core/types/internal/config.ts @@ -64,7 +64,6 @@ export interface OAuthConfig extends XmApiBaseConfig { accessToken: string; refreshToken: string; clientId: string; - expiresAt?: string; // ISO timestamp when the access token expires } /** diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index a15f470..3b4a8f4 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -327,7 +327,7 @@ Deno.test('GroupsEndpoint', async (t) => { expect(thrownError).toBeInstanceOf(XmApiError); const xmError = thrownError as XmApiError; expect(xmError.message).toBe('Request failed'); // Generic message for network errors - expect(xmError.response).toBeUndefined(); // No response for network errors + expect(xmError.response).toBeNull(); // No response for network errors } finally { sendStub.restore(); } From 1739929e2ce9f92f8ffd88ed364f2f6aa5eaa185 Mon Sep 17 00:00:00 2001 From: johan Date: Mon, 16 Jun 2025 23:20:40 -0700 Subject: [PATCH 050/101] Remove dead code --- src/core/types/internal/oauth.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/core/types/internal/oauth.ts b/src/core/types/internal/oauth.ts index e19d867..057ca5f 100644 --- a/src/core/types/internal/oauth.ts +++ b/src/core/types/internal/oauth.ts @@ -17,25 +17,3 @@ export interface OAuth2TokenResponse { /** The type of token, typically 'Bearer' */ token_type: 'Bearer' | string; } - -/** - * Basic token data required for OAuth2 authentication. - */ -interface TokenData { - /** Token to use for authenticating requests */ - accessToken: string; - /** Token to use for getting a new access token */ - refreshToken: string; - /** Client ID used for OAuth2 server authentication */ - clientId: string; -} - -/** - * Data structure for managing OAuth2 tokens with metadata and helper methods. - */ -export interface TokenState extends TokenData { - /** ISO timestamp when the access token expires */ - expiresAt: string; - /** Scopes granted to the token */ - scopes: string[]; -} From 73688a4011696af502064c5b9a02173dc4bc1abc Mon Sep 17 00:00:00 2001 From: johan Date: Mon, 16 Jun 2025 23:33:07 -0700 Subject: [PATCH 051/101] Remove dead code --- src/core/utils/config-validation.test.ts | 81 ------------------------ src/core/utils/config-validation.ts | 17 ----- 2 files changed, 98 deletions(-) diff --git a/src/core/utils/config-validation.test.ts b/src/core/utils/config-validation.test.ts index a6eaafb..39950e2 100644 --- a/src/core/utils/config-validation.test.ts +++ b/src/core/utils/config-validation.test.ts @@ -7,7 +7,6 @@ Deno.test('validateConfig - null/undefined config', () => { expect(() => validateConfig(null)).toThrow(XmApiError); // @ts-ignore - Testing invalid input types expect(() => validateConfig(null)).toThrow('Configuration object is required'); - // @ts-ignore - Testing invalid input types expect(() => validateConfig(undefined)).toThrow(XmApiError); // @ts-ignore - Testing invalid input types @@ -19,12 +18,10 @@ Deno.test('validateConfig - non-object config', () => { expect(() => validateConfig('string')).toThrow(XmApiError); // @ts-ignore - Testing invalid input types expect(() => validateConfig('string')).toThrow('Expected object'); - // @ts-ignore - Testing invalid input types expect(() => validateConfig(123)).toThrow(XmApiError); // @ts-ignore - Testing invalid input types expect(() => validateConfig(123)).toThrow('Expected object'); - // @ts-ignore - Testing invalid input types expect(() => validateConfig(true)).toThrow(XmApiError); // @ts-ignore - Testing invalid input types @@ -36,7 +33,6 @@ Deno.test('validateConfig - array config', () => { expect(() => validateConfig([])).toThrow(XmApiError); // @ts-ignore - Testing invalid input types expect(() => validateConfig([])).toThrow('Expected object'); - // @ts-ignore - Testing invalid input types expect(() => validateConfig(['test'])).toThrow(XmApiError); // @ts-ignore - Testing invalid input types @@ -48,12 +44,10 @@ Deno.test('validateConfig - invalid hostname', () => { expect(() => validateConfig({ hostname: 123 })).toThrow(XmApiError); // @ts-ignore - Testing invalid property types expect(() => validateConfig({ hostname: 123 })).toThrow('hostname must be a string'); - // @ts-ignore - Testing invalid property types expect(() => validateConfig({ hostname: null })).toThrow(XmApiError); // @ts-ignore - Testing invalid property types expect(() => validateConfig({ hostname: null })).toThrow('hostname must be a string'); - // @ts-ignore - Testing invalid property types expect(() => validateConfig({ hostname: undefined })).toThrow(XmApiError); // @ts-ignore - Testing invalid property types @@ -62,19 +56,16 @@ Deno.test('validateConfig - invalid hostname', () => { Deno.test('validateConfig - invalid maxRetries', () => { const baseConfig = { hostname: 'test.com', username: 'user', password: 'pass' }; - // @ts-ignore - Testing invalid property types expect(() => validateConfig({ ...baseConfig, maxRetries: 'invalid' })).toThrow(XmApiError); // @ts-ignore - Testing invalid property types expect(() => validateConfig({ ...baseConfig, maxRetries: 'invalid' })).toThrow( 'maxRetries must be a non-negative integer', ); - expect(() => validateConfig({ ...baseConfig, maxRetries: -1 })).toThrow(XmApiError); expect(() => validateConfig({ ...baseConfig, maxRetries: -1 })).toThrow( 'maxRetries must be a non-negative integer', ); - expect(() => validateConfig({ ...baseConfig, maxRetries: 1.5 })).toThrow(XmApiError); expect(() => validateConfig({ ...baseConfig, maxRetries: 1.5 })).toThrow( 'maxRetries must be a non-negative integer', @@ -83,7 +74,6 @@ Deno.test('validateConfig - invalid maxRetries', () => { Deno.test('validateConfig - valid maxRetries', () => { const baseConfig = { hostname: 'test.com', username: 'user', password: 'pass' }; - expect(() => validateConfig({ ...baseConfig, maxRetries: 0 })).not.toThrow(); expect(() => validateConfig({ ...baseConfig, maxRetries: 3 })).not.toThrow(); expect(() => validateConfig({ ...baseConfig, maxRetries: 10 })).not.toThrow(); @@ -92,7 +82,6 @@ Deno.test('validateConfig - valid maxRetries', () => { Deno.test('validateConfig - no auth method provided', () => { // @ts-ignore - Testing incomplete config const config = { hostname: 'test.com' }; - // @ts-ignore - Testing incomplete config expect(() => validateConfig(config)).toThrow(XmApiError); // @ts-ignore - Testing incomplete config @@ -110,7 +99,6 @@ Deno.test('validateConfig - multiple auth methods', () => { authorizationCode: 'code', clientId: 'client', }; - expect(() => validateConfig(config)).toThrow(XmApiError); expect(() => validateConfig(config)).toThrow( 'Cannot mix basic auth, authorization code, and OAuth token fields', @@ -119,7 +107,6 @@ Deno.test('validateConfig - multiple auth methods', () => { Deno.test('validateConfig - basic auth validation', () => { const baseConfig = { hostname: 'test.com' }; - // Invalid username types // @ts-ignore - Testing invalid property types expect(() => validateConfig({ ...baseConfig, username: 123, password: 'pass' })).toThrow( @@ -129,7 +116,6 @@ Deno.test('validateConfig - basic auth validation', () => { expect(() => validateConfig({ ...baseConfig, username: 123, password: 'pass' })).toThrow( 'username must be a non-empty string', ); - // @ts-ignore - Testing invalid property types expect(() => validateConfig({ ...baseConfig, username: null, password: 'pass' })).toThrow( XmApiError, @@ -138,7 +124,6 @@ Deno.test('validateConfig - basic auth validation', () => { expect(() => validateConfig({ ...baseConfig, username: null, password: 'pass' })).toThrow( 'username must be a non-empty string', ); - // Empty username expect(() => validateConfig({ ...baseConfig, username: '', password: 'pass' })).toThrow( XmApiError, @@ -146,7 +131,6 @@ Deno.test('validateConfig - basic auth validation', () => { expect(() => validateConfig({ ...baseConfig, username: '', password: 'pass' })).toThrow( 'username must be a non-empty string', ); - // Invalid password types // @ts-ignore - Testing invalid property types expect(() => validateConfig({ ...baseConfig, username: 'user', password: 123 })).toThrow( @@ -156,7 +140,6 @@ Deno.test('validateConfig - basic auth validation', () => { expect(() => validateConfig({ ...baseConfig, username: 'user', password: 123 })).toThrow( 'password must be a non-empty string', ); - // @ts-ignore - Testing invalid property types expect(() => validateConfig({ ...baseConfig, username: 'user', password: null })).toThrow( XmApiError, @@ -165,7 +148,6 @@ Deno.test('validateConfig - basic auth validation', () => { expect(() => validateConfig({ ...baseConfig, username: 'user', password: null })).toThrow( 'password must be a non-empty string', ); - // Empty password expect(() => validateConfig({ ...baseConfig, username: 'user', password: '' })).toThrow( XmApiError, @@ -181,13 +163,11 @@ Deno.test('validateConfig - valid basic auth', () => { username: 'user', password: 'pass', }; - expect(() => validateConfig(config)).not.toThrow(); }); Deno.test('validateConfig - auth code validation', () => { const baseConfig = { hostname: 'test.com' }; - // Invalid authorizationCode types // @ts-ignore - Testing invalid property types expect(() => validateConfig({ ...baseConfig, authorizationCode: 123, clientId: 'client' })) @@ -201,7 +181,6 @@ Deno.test('validateConfig - auth code validation', () => { .toThrow(XmApiError); expect(() => validateConfig({ ...baseConfig, authorizationCode: '', clientId: 'client' })) .toThrow('authorizationCode must be a non-empty string'); - // Missing clientId // @ts-ignore - Testing incomplete config expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code' })).toThrow(XmApiError); @@ -209,7 +188,6 @@ Deno.test('validateConfig - auth code validation', () => { expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code' })).toThrow( 'clientId must be a non-empty string', ); - // Invalid clientId types // @ts-ignore - Testing invalid property types expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code', clientId: 123 })).toThrow( @@ -219,7 +197,6 @@ Deno.test('validateConfig - auth code validation', () => { expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code', clientId: 123 })).toThrow( 'clientId must be a non-empty string', ); - // Empty clientId expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code', clientId: '' })).toThrow( XmApiError, @@ -227,7 +204,6 @@ Deno.test('validateConfig - auth code validation', () => { expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code', clientId: '' })).toThrow( 'clientId must be a non-empty string', ); - // Invalid clientSecret type (when provided) // @ts-ignore - Testing invalid property types expect(() => @@ -257,21 +233,17 @@ Deno.test('validateConfig - valid auth code', () => { authorizationCode: 'code', clientId: 'client', }; - expect(() => validateConfig(config)).not.toThrow(); - // With optional clientSecret const configWithSecret = { ...config, clientSecret: 'secret', }; - expect(() => validateConfig(configWithSecret)).not.toThrow(); }); Deno.test('validateConfig - OAuth tokens validation', () => { const baseConfig = { hostname: 'test.com' }; - // Invalid accessToken types // @ts-ignore - Testing invalid property types expect(() => @@ -293,7 +265,6 @@ Deno.test('validateConfig - OAuth tokens validation', () => { clientId: 'client', }) ).toThrow('accessToken must be a non-empty string'); - // Empty accessToken expect(() => validateConfig({ ...baseConfig, accessToken: '', refreshToken: 'refresh', clientId: 'client' }) @@ -301,7 +272,6 @@ Deno.test('validateConfig - OAuth tokens validation', () => { expect(() => validateConfig({ ...baseConfig, accessToken: '', refreshToken: 'refresh', clientId: 'client' }) ).toThrow('accessToken must be a non-empty string'); - // Invalid refreshToken types // @ts-ignore - Testing invalid property types expect(() => @@ -323,7 +293,6 @@ Deno.test('validateConfig - OAuth tokens validation', () => { clientId: 'client', }) ).toThrow('refreshToken must be a non-empty string'); - // Empty refreshToken expect(() => validateConfig({ ...baseConfig, accessToken: 'access', refreshToken: '', clientId: 'client' }) @@ -331,7 +300,6 @@ Deno.test('validateConfig - OAuth tokens validation', () => { expect(() => validateConfig({ ...baseConfig, accessToken: 'access', refreshToken: '', clientId: 'client' }) ).toThrow('refreshToken must be a non-empty string'); - // Missing clientId // @ts-ignore - Testing incomplete config expect(() => validateConfig({ ...baseConfig, accessToken: 'access', refreshToken: 'refresh' })) @@ -339,7 +307,6 @@ Deno.test('validateConfig - OAuth tokens validation', () => { // @ts-ignore - Testing incomplete config expect(() => validateConfig({ ...baseConfig, accessToken: 'access', refreshToken: 'refresh' })) .toThrow('clientId must be a non-empty string'); - // Invalid clientId types // @ts-ignore - Testing invalid property types expect(() => @@ -361,7 +328,6 @@ Deno.test('validateConfig - OAuth tokens validation', () => { clientId: 123, }) ).toThrow('clientId must be a non-empty string'); - // Empty clientId expect(() => validateConfig({ ...baseConfig, accessToken: 'access', refreshToken: 'refresh', clientId: '' }) @@ -369,30 +335,6 @@ Deno.test('validateConfig - OAuth tokens validation', () => { expect(() => validateConfig({ ...baseConfig, accessToken: 'access', refreshToken: 'refresh', clientId: '' }) ).toThrow('clientId must be a non-empty string'); - - // Invalid expiresAt type (when provided) - // @ts-ignore - Testing invalid property types - expect(() => - validateConfig({ - ...baseConfig, - accessToken: 'access', - refreshToken: 'refresh', - clientId: 'client', - // @ts-ignore - Testing invalid property types - expiresAt: 123, - }) - ).toThrow(XmApiError); - // @ts-ignore - Testing invalid property types - expect(() => - validateConfig({ - ...baseConfig, - accessToken: 'access', - refreshToken: 'refresh', - clientId: 'client', - // @ts-ignore - Testing invalid property types - expiresAt: 123, - }) - ).toThrow('expiresAt must be a string'); }); Deno.test('validateConfig - valid OAuth tokens', () => { @@ -402,16 +344,7 @@ Deno.test('validateConfig - valid OAuth tokens', () => { refreshToken: 'refresh', clientId: 'client', }; - expect(() => validateConfig(config)).not.toThrow(); - - // With optional expiresAt - const configWithExpiry = { - ...config, - expiresAt: '2025-12-31T23:59:59Z', - }; - - expect(() => validateConfig(configWithExpiry)).not.toThrow(); }); Deno.test('validateConfig - edge cases', () => { @@ -422,9 +355,7 @@ Deno.test('validateConfig - edge cases', () => { password: 'pass', maxRetries: undefined, }; - expect(() => validateConfig(config)).not.toThrow(); - // clientSecret undefined should be fine const authCodeConfig = { hostname: 'test.com', @@ -432,17 +363,5 @@ Deno.test('validateConfig - edge cases', () => { clientId: 'client', clientSecret: undefined, }; - expect(() => validateConfig(authCodeConfig)).not.toThrow(); - - // expiresAt undefined should be fine - const oauthConfig = { - hostname: 'test.com', - accessToken: 'access', - refreshToken: 'refresh', - clientId: 'client', - expiresAt: undefined, - }; - - expect(() => validateConfig(oauthConfig)).not.toThrow(); }); diff --git a/src/core/utils/config-validation.ts b/src/core/utils/config-validation.ts index c473257..92d8549 100644 --- a/src/core/utils/config-validation.ts +++ b/src/core/utils/config-validation.ts @@ -10,16 +10,13 @@ export function validateConfig(config: XmApiConfig): void { if (config === null || config === undefined) { throw new XmApiError('Invalid config: Configuration object is required'); } - if (typeof config !== 'object' || Array.isArray(config)) { throw new XmApiError('Invalid config: Expected object'); } - // 2. Validate hostname if (typeof config.hostname !== 'string') { throw new XmApiError('Invalid config: hostname must be a string'); } - // 3. Validate maxRetries if provided if (config.maxRetries !== undefined) { if ( @@ -29,27 +26,22 @@ export function validateConfig(config: XmApiConfig): void { throw new XmApiError('Invalid config: maxRetries must be a non-negative integer'); } } - // 4. Determine which auth methods are present const hasBasicAuth = 'username' in config && 'password' in config; const hasAuthCode = 'authorizationCode' in config; const hasOAuthTokens = 'accessToken' in config && 'refreshToken' in config; - const configCount = [hasBasicAuth, hasAuthCode, hasOAuthTokens].filter(Boolean).length; - // 5. Validate exactly one auth method is provided if (configCount === 0) { throw new XmApiError( 'Invalid config: Must provide either basic auth credentials, authorization code, or OAuth tokens', ); } - if (configCount > 1) { throw new XmApiError( 'Invalid config: Cannot mix basic auth, authorization code, and OAuth token fields', ); } - // 6. Validate required fields and types for each config type if (hasBasicAuth) { if (typeof config.username !== 'string' || !config.username) { @@ -59,7 +51,6 @@ export function validateConfig(config: XmApiConfig): void { throw new XmApiError('Invalid config: password must be a non-empty string'); } } - if (hasAuthCode) { if (typeof config.authorizationCode !== 'string' || !config.authorizationCode) { throw new XmApiError('Invalid config: authorizationCode must be a non-empty string'); @@ -75,7 +66,6 @@ export function validateConfig(config: XmApiConfig): void { throw new XmApiError('Invalid config: clientSecret must be a string'); } } - if (hasOAuthTokens) { if (typeof config.accessToken !== 'string' || !config.accessToken) { throw new XmApiError('Invalid config: accessToken must be a non-empty string'); @@ -86,12 +76,5 @@ export function validateConfig(config: XmApiConfig): void { if (!('clientId' in config) || typeof config.clientId !== 'string' || !config.clientId) { throw new XmApiError('Invalid config: clientId must be a non-empty string'); } - // Validate optional expiresAt if provided - if ( - 'expiresAt' in config && config.expiresAt !== undefined && - typeof config.expiresAt !== 'string' - ) { - throw new XmApiError('Invalid config: expiresAt must be a string'); - } } } From 2668979616c1cb965895d2391e9051af45569646 Mon Sep 17 00:00:00 2001 From: johan Date: Mon, 16 Jun 2025 23:42:48 -0700 Subject: [PATCH 052/101] Remove dead code --- src/endpoints/oauth/types.ts | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 src/endpoints/oauth/types.ts diff --git a/src/endpoints/oauth/types.ts b/src/endpoints/oauth/types.ts deleted file mode 100644 index 6029f9c..0000000 --- a/src/endpoints/oauth/types.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Types specific to the OAuth endpoint. - * These types define the request and response structures for OAuth token operations. - */ - -/** - * The response returned when successfully obtaining OAuth tokens. - * Contains only the fields our library consumers need for functionality. - */ -export interface TokenResponse { - /** The access token to use for authenticated requests */ - access_token: string; - /** The type of token, typically 'Bearer' */ - token_type: string; - /** Token to use to get a new access token when it expires */ - refresh_token: string; - /** How many seconds until the access token expires */ - expires_in: number; -} From 0e1bea7fe185ff43707142389d27e7e752b794c7 Mon Sep 17 00:00:00 2001 From: johan Date: Tue, 17 Jun 2025 09:23:07 -0700 Subject: [PATCH 053/101] Error consistency --- src/core/errors.ts | 10 ++++++--- src/core/request-handler.test.ts | 36 +++++++++++++----------------- src/core/request-handler.ts | 6 +++-- src/endpoints/groups/index.test.ts | 13 ++++++----- 4 files changed, 34 insertions(+), 31 deletions(-) diff --git a/src/core/errors.ts b/src/core/errors.ts index cb59b8c..c47cabc 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -34,14 +34,18 @@ export class XmApiError extends Error { const finalMessage = (message && message.trim()) ? message : (response ? XmApiError.extractErrorMessage(response) : message); - super(finalMessage); + + // Pass cause to parent Error constructor using the standard format + // to preserve the original error context for complete stack traces + super(finalMessage, cause ? { cause } : undefined); this.name = 'XmApiError'; // Ensure proper prototype chain for instanceof checks Object.setPrototypeOf(this, XmApiError.prototype); - // Capture stack trace - if (Error.captureStackTrace) { + // Only capture a new stack trace if we don't have a cause + // When we have a cause, we want to preserve the original stack trace + if (!cause && Error.captureStackTrace) { Error.captureStackTrace(this, this.constructor); } } diff --git a/src/core/request-handler.test.ts b/src/core/request-handler.test.ts index 5af4fcf..7a9dc7c 100644 --- a/src/core/request-handler.test.ts +++ b/src/core/request-handler.test.ts @@ -7,7 +7,16 @@ * - Uses try/finally blocks to ensure proper cleanup of stubs * - Creates mock data objects for reusable test responses * - Follows descriptive test step naming conventions - * - Tests both success and error scenarios comprehensively + * - Test await t.step('logs error when token refresh fails', async () => { + const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup({ + accessToken: 'expired-token', + refreshToken: 'invalid-refresh-token', + clientId: 'test-client-id', + responses: [ + mockUnauthorizedResponse, + { status: 400, headers: {}, body: { error: 'invalid_grant' } }, + ], + });cess and error scenarios comprehensively */ import { expect } from 'std/expect/mod.ts'; @@ -456,7 +465,7 @@ Deno.test('RequestHandler', async (t) => { }); await t.step('logs error when token refresh fails', async () => { - const { mockHttpClient, requestHandler, mockLogger } = createRequestHandlerTestSetup({ + const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup({ accessToken: 'expired-token', refreshToken: 'invalid-refresh-token', clientId: 'test-client-id', @@ -466,9 +475,6 @@ Deno.test('RequestHandler', async (t) => { ], }); - // Stub the error method to capture calls - const errorStub = stub(mockLogger, 'error', () => {}); - try { let thrownError: unknown; try { @@ -480,12 +486,11 @@ Deno.test('RequestHandler', async (t) => { expect(thrownError).toBeInstanceOf(XmApiError); expect(mockHttpClient.requests.length).toBe(2); // Initial 401 + failed token refresh - // Verify error logger was called - expect(errorStub.calls.length).toBe(1); - expect(errorStub.calls[0].args[0]).toBe('Failed to refresh token:'); - expect(errorStub.calls[0].args[1]).toBeInstanceOf(XmApiError); + // Verify error details are correct + const xmError = thrownError as XmApiError; + expect(xmError.message).toBe('Failed to refresh token'); + expect(xmError.response?.status).toBe(400); } finally { - errorStub.restore(); mockHttpClient.reset(); } }); @@ -537,7 +542,7 @@ Deno.test('RequestHandler', async (t) => { }); await t.step('throws error when token refresh returns non-200 status', async () => { - const { mockHttpClient, requestHandler, mockLogger } = createRequestHandlerTestSetup({ + const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup({ accessToken: 'expired-token', refreshToken: 'invalid-refresh-token', clientId: 'test-client-id', @@ -547,9 +552,6 @@ Deno.test('RequestHandler', async (t) => { ], }); - // Stub the error method to capture calls - const errorStub = stub(mockLogger, 'error', () => {}); - try { let thrownError: unknown; try { @@ -562,13 +564,7 @@ Deno.test('RequestHandler', async (t) => { const xmError = thrownError as XmApiError; expect(xmError.message).toBe('Failed to refresh token'); expect(xmError.response?.status).toBe(401); - - // Verify error logger was called - expect(errorStub.calls.length).toBe(1); - expect(errorStub.calls[0].args[0]).toBe('Failed to refresh token:'); - expect(errorStub.calls[0].args[1]).toBeInstanceOf(XmApiError); } finally { - errorStub.restore(); mockHttpClient.reset(); } }); diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index 1a0c928..b1be05b 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -213,8 +213,10 @@ export class RequestHandler { const tokenResponse = response.body as OAuth2TokenResponse; await this.handleNewOAuthTokens(tokenResponse, this.mutableAuthState.clientId); } catch (error) { - this.logger.error('Failed to refresh token:', error); - throw error; + if (error instanceof XmApiError) { + throw error; + } + throw new XmApiError('Failed to refresh token', null, error); } } diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index 3b4a8f4..24aafc9 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -631,7 +631,7 @@ Deno.test('GroupsEndpoint', async (t) => { }); await t.step('logs error when token refresh fails', async () => { - const { mockHttpClient, mockLogger, endpoint } = createEndpointTestSetup({ + const { mockHttpClient, endpoint } = createEndpointTestSetup({ accessToken: 'expired-token', refreshToken: 'invalid-refresh-token', clientId: 'test-client-id', @@ -660,7 +660,7 @@ Deno.test('GroupsEndpoint', async (t) => { // Otherwise it's the main API request return Promise.resolve(unauthorizedResponse); }); - const errorStub = stub(mockLogger, 'error', () => {}); + try { let thrownError: unknown; try { @@ -670,12 +670,13 @@ Deno.test('GroupsEndpoint', async (t) => { } expect(thrownError).toBeInstanceOf(XmApiError); expect(sendStub.calls.length).toBe(2); // initial request (401), failed token refresh - expect(errorStub.calls.length).toBe(1); - expect(errorStub.calls[0].args[0]).toBe('Failed to refresh token:'); - expect(errorStub.calls[0].args[1]).toBeInstanceOf(XmApiError); + + // Verify error details are correct + const xmError = thrownError as XmApiError; + expect(xmError.message).toBe('Failed to refresh token'); + expect(xmError.response?.status).toBe(400); } finally { sendStub.restore(); - errorStub.restore(); } }); From 8fe7743f9bfcf49f13272fa921a439b49e4e1ff0 Mon Sep 17 00:00:00 2001 From: johan Date: Tue, 17 Jun 2025 09:31:49 -0700 Subject: [PATCH 054/101] Build formData safely with new URLSearchParams instead of rawdogging string interpolations without concerns for URL encoding --- src/endpoints/oauth/index.ts | 38 +++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/src/endpoints/oauth/index.ts b/src/endpoints/oauth/index.ts index b0422c7..6327fc6 100644 --- a/src/endpoints/oauth/index.ts +++ b/src/endpoints/oauth/index.ts @@ -102,11 +102,13 @@ export class OAuthEndpoint { 'Client ID discovery not yet implemented - please provide explicit clientId', ); } - let payload = - `grant_type=password&client_id=${clientId}&username=${username}&password=${password}`; - if (clientSecret) { - payload += `&client_secret=${clientSecret}`; - } + const payload = this.buildFormData({ + grant_type: 'password', + client_id: clientId, + username, + password, + client_secret: clientSecret, + }); return await this.getOAuthToken({ payload, clientId }); } @@ -128,10 +130,28 @@ export class OAuthEndpoint { }, ): Promise> { const { authorizationCode, clientId, clientSecret } = options; - let payload = `grant_type=authorization_code&authorization_code=${authorizationCode}`; - if (clientSecret) { - payload += `&client_secret=${clientSecret}`; - } + const payload = this.buildFormData({ + grant_type: 'authorization_code', + authorization_code: authorizationCode, + client_secret: clientSecret, + }); return await this.getOAuthToken({ payload, clientId }); } + + /** + * Builds form-encoded payload using URLSearchParams for proper URL encoding. + * Only includes parameters that have defined values. + * + * @param params - Key-value pairs for the form data + * @returns URL-encoded form data string + */ + private buildFormData(params: Record): string { + const formData = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + formData.set(key, value); + } + }); + return formData.toString(); + } } From f9afe9d1f6f0e1673830191bbdac0b979a8635eb Mon Sep 17 00:00:00 2001 From: johan Date: Tue, 17 Jun 2025 22:35:09 -0700 Subject: [PATCH 055/101] Renamed refreshToken method to avoid confusion with property of the same name Truly centralize the http request issuer into a sendWithLogging method --- src/core/errors.ts | 10 +------ src/core/request-handler.test.ts | 42 +++++++++++++++++++++--------- src/core/request-handler.ts | 32 ++++++++++++++++------- src/endpoints/groups/index.test.ts | 30 ++++++++++++++------- 4 files changed, 75 insertions(+), 39 deletions(-) diff --git a/src/core/errors.ts b/src/core/errors.ts index c47cabc..bd0e8f0 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -59,39 +59,31 @@ export class XmApiError extends Error { status: number; }): string { // Default fallback message - const defaultMessage = `DEBUG: Request failed with status ${response.status}`; - + const defaultMessage = `Request failed with status ${response.status}`; // If no response body, use default if (!response.body) { return defaultMessage; } - // If response body is a string, use it directly if it's not empty if (typeof response.body === 'string') { const trimmed = response.body.trim(); return trimmed || defaultMessage; } - // If response body is not an object, use default if (typeof response.body !== 'object' || Array.isArray(response.body)) { return defaultMessage; } - const body = response.body as Record; - // xMatters API typically uses 'reason' for error type and 'message' for details const reason = typeof body.reason === 'string' ? body.reason.trim() : ''; const message = typeof body.message === 'string' ? body.message.trim() : ''; - // If we have both reason and message, combine them if (reason && message) { return `${reason}: ${message}`; } - // If we only have one, use it if (reason) return reason; if (message) return message; - // Fall back to default message return defaultMessage; } diff --git a/src/core/request-handler.test.ts b/src/core/request-handler.test.ts index 7a9dc7c..51e85da 100644 --- a/src/core/request-handler.test.ts +++ b/src/core/request-handler.test.ts @@ -451,10 +451,16 @@ Deno.test('RequestHandler', async (t) => { expect(mockHttpClient.requests.length).toBe(2); // Verify debug logger was called with correct retry message - // Should be: initial request log + retry message + retry request log = 3 calls - expect(debugStub.calls.length).toBe(3); - expect(debugStub.calls[1].args[0]).toBe( - 'DEBUG: Request failed with status 429, retrying in 1000ms (attempt 1/3)', + // Should be: + // initial request --> + // + initial request <-- + // + retry message + // + retry request --> + // + retry request <-- + // = 5 calls + expect(debugStub.calls.length).toBe(5); + expect(debugStub.calls[2].args[0]).toBe( + 'Request failed with status 429, retrying in 1000ms (attempt 1/3)', ); } finally { debugStub.restore(); @@ -603,10 +609,16 @@ Deno.test('RequestHandler', async (t) => { expect(mockHttpClient.requests.length).toBe(2); // Verify debug logger was called with exponential backoff message - // Should be: initial request log + retry message + retry request log = 3 calls - expect(debugStub.calls.length).toBe(3); - expect(debugStub.calls[1].args[0]).toBe( - 'DEBUG: Request failed with status 503, retrying in 1000ms (attempt 1/3)', + // Should be: + // initial request --> + // + initial request <-- + // + retry message + // + retry request --> + // + retry request <-- + // = 5 calls + expect(debugStub.calls.length).toBe(5); + expect(debugStub.calls[2].args[0]).toBe( + 'Request failed with status 503, retrying in 1000ms (attempt 1/3)', ); } finally { debugStub.restore(); @@ -650,10 +662,16 @@ Deno.test('RequestHandler', async (t) => { expect(mockHttpClient.requests.length).toBe(2); // Verify debug logger was called with Retry-After header value - // Should be: initial request log + retry message + retry request log = 3 calls - expect(debugStub.calls.length).toBe(3); - expect(debugStub.calls[1].args[0]).toBe( - 'DEBUG: Request failed with status 429, retrying in 5000ms (attempt 1/3)', + // Should be: + // initial request --> + // + initial request <-- + // + retry message + // + retry request --> + // + retry request <-- + // = 5 calls + expect(debugStub.calls.length).toBe(5); + expect(debugStub.calls[2].args[0]).toBe( + 'Request failed with status 429, retrying in 5000ms (attempt 1/3)', ); } finally { debugStub.restore(); diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index b1be05b..36e60a7 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -1,4 +1,4 @@ -import type { HttpClient, HttpResponse } from './types/internal/http.ts'; +import type { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; import { isAuthCodeConfig, isBasicAuthConfig, @@ -80,7 +80,7 @@ export class RequestHandler { ): Promise> { // Check if token refresh is needed before making the request if (this.mutableAuthState.type === 'oauth' && this.isTokenExpired()) { - await this.refreshToken(); + await this.refreshAccessToken(); } const fullRequest = this.requestBuilder.build(request); // Add authorization header unless explicitly skipped @@ -94,8 +94,7 @@ export class RequestHandler { } } try { - this.logger.debug(`DEBUG: Sending request: ${fullRequest.method} ${fullRequest.url}`); - const response = await this.client.send(fullRequest); + const response = await this.sendWithLogging(fullRequest); if (response.status >= 400) { const currentAttempt = fullRequest.retryAttempt ?? 0; // Handle OAuth token expiry/refresh first @@ -104,7 +103,7 @@ export class RequestHandler { this.mutableAuthState.type === 'oauth' && currentAttempt === 0 ) { - await this.refreshToken(); + await this.refreshAccessToken(); // Retry the original request with new token return this.send({ ...request, @@ -127,7 +126,7 @@ export class RequestHandler { } } this.logger.debug( - `DEBUG: Request failed with status ${response.status}, retrying in ${finalDelay}ms (attempt ${ + `Request failed with status ${response.status}, retrying in ${finalDelay}ms (attempt ${ currentAttempt + 1 }/${this.maxRetries})`, ); @@ -168,6 +167,21 @@ export class RequestHandler { return this.send({ ...options, method: 'DELETE' }); } + /** + * Sends an HTTP request with logging. + * This wrapper ensures consistent logging across all HTTP calls. + */ + private async sendWithLogging( + request: HttpRequest, + ): Promise { + const startTime = Date.now(); + this.logger.debug(`--> ${request.method} ${request.url}`); + const response = await this.client.send(request); + const duration = Date.now() - startTime; + this.logger.debug(`<-- ${response.status} (${duration}ms)`); + return response; + } + /** * Creates the authorization header value based on the authentication type */ @@ -184,7 +198,7 @@ export class RequestHandler { } } - private async refreshToken(): Promise { + private async refreshAccessToken(): Promise { try { if (this.mutableAuthState.type !== 'oauth') { throw new XmApiError('No OAuth configuration available for token refresh'); @@ -204,9 +218,9 @@ export class RequestHandler { body: params.toString(), }); this.logger.debug( - `DEBUG: Refreshing token for client ${this.mutableAuthState.clientId}`, + `Refreshing token for client ${this.mutableAuthState.clientId}`, ); - const response = await this.client.send(refreshRequest); + const response = await this.sendWithLogging(refreshRequest); if (response.status < 200 || response.status >= 300) { throw new XmApiError('Failed to refresh token', response); } diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index 24aafc9..dbf91ca 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -492,10 +492,16 @@ Deno.test('GroupsEndpoint', async (t) => { const retryRequest: HttpRequest = sendStub.calls[1].args[0]; expect(retryRequest.method).toBe('GET'); expect(retryRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups'); - // Should be: initial request log + retry message + retry request log = 3 calls - expect(debugStub.calls.length).toBe(3); - expect(debugStub.calls[1].args[0]).toBe( - 'DEBUG: Request failed with status 429, retrying in 2000ms (attempt 1/3)', + // Should be: + // initial request --> + // + initial request <-- + // + retry message + // + retry request --> + // + retry request <-- + // = 5 calls + expect(debugStub.calls.length).toBe(5); + expect(debugStub.calls[2].args[0]).toBe( + 'Request failed with status 429, retrying in 2000ms (attempt 1/3)', ); } finally { sendStub.restore(); @@ -554,10 +560,16 @@ Deno.test('GroupsEndpoint', async (t) => { expect(response.body).toEqual(successResponse.body); expect(sendStub.calls.length).toBe(2); // Verify debug logger was called with correct retry message - // Should be: initial request log + retry message + retry request log = 3 calls - expect(debugStub.calls.length).toBe(3); - expect(debugStub.calls[1].args[0]).toBe( - 'DEBUG: Request failed with status 503, retrying in 1000ms (attempt 1/3)', + // Should be: + // initial request --> + // + initial request <-- + // + retry message + // + retry request --> + // + retry request <-- + // = 5 calls + expect(debugStub.calls.length).toBe(5); + expect(debugStub.calls[2].args[0]).toBe( + 'Request failed with status 503, retrying in 1000ms (attempt 1/3)', ); } finally { sendStub.restore(); @@ -701,7 +713,7 @@ Deno.test('GroupsEndpoint', async (t) => { expect(sendStub.calls.length).toBe(1); expect(thrownError).toBeInstanceOf(XmApiError); const xmError = thrownError as XmApiError; - expect(xmError.message).toBe('DEBUG: Request failed with status 400'); + expect(xmError.message).toBe('Request failed with status 400'); expect(xmError.response?.status).toBe(400); expect(xmError.response?.body).toBe(''); } finally { From 0d69b8dfe8e59883da898146b4baf4eded659d98 Mon Sep 17 00:00:00 2001 From: johan Date: Wed, 18 Jun 2025 21:04:09 -0700 Subject: [PATCH 056/101] renamed file for clarity --- src/core/request-handler.ts | 2 +- src/core/resource-client.ts | 2 +- src/core/types/internal/{methods.ts => http-methods.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename src/core/types/internal/{methods.ts => http-methods.ts} (100%) diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index 36e60a7..fed2624 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -12,7 +12,7 @@ import type { DeleteOptions, GetOptions, RequestWithBodyOptions, -} from './types/internal/methods.ts'; +} from './types/internal/http-methods.ts'; import { XmApiError } from './errors.ts'; import type { OAuth2TokenResponse } from './types/internal/oauth.ts'; import { RequestBuilder, type RequestBuildOptions } from './request-builder.ts'; diff --git a/src/core/resource-client.ts b/src/core/resource-client.ts index c1cd4e3..9844376 100644 --- a/src/core/resource-client.ts +++ b/src/core/resource-client.ts @@ -2,7 +2,7 @@ import type { DeleteOptions, GetOptions, RequestWithBodyOptions, -} from './types/internal/methods.ts'; +} from './types/internal/http-methods.ts'; import type { RequestHandler } from './request-handler.ts'; import { XmApiError } from './errors.ts'; diff --git a/src/core/types/internal/methods.ts b/src/core/types/internal/http-methods.ts similarity index 100% rename from src/core/types/internal/methods.ts rename to src/core/types/internal/http-methods.ts From f20e9afc7d713358bffe3e32a01f066177b9ccf3 Mon Sep 17 00:00:00 2001 From: johan Date: Wed, 18 Jun 2025 21:08:02 -0700 Subject: [PATCH 057/101] delete unnecessary file --- docs/oauth-dx-refactor-plan.md | 53 ---------------------------------- 1 file changed, 53 deletions(-) delete mode 100644 docs/oauth-dx-refactor-plan.md diff --git a/docs/oauth-dx-refactor-plan.md b/docs/oauth-dx-refactor-plan.md deleted file mode 100644 index ca03cac..0000000 --- a/docs/oauth-dx-refactor-plan.md +++ /dev/null @@ -1,53 +0,0 @@ -# OAuth DX - -### Scenario 1: Basic Auth → OAuth (Password Grant) - -```typescript -const xm = new XmApi({ - hostname: 'https://company.xmatters.com', - username: 'user@company.com', - password: 'secret', -}); - -// Use basic auth for initial API calls -await xm.groups.get(); - -// Switch to OAuth - auto-discovers clientId -await xm.oauth.obtainTokens(); - -// Or provide explicit clientId to skip discovery -await xm.oauth.obtainTokens({ clientId: 'my-client-id' }); - -// Subsequent API calls are now oauth authenticated -await xm.groups.get(); -``` - -### Scenario 2: Auth Code → OAuth - -```typescript -const xm = new XmApi({ - hostname: 'https://company.xmatters.com', - authCode: 'received-from-redirect', - clientId: 'web-app-client-id', // Required - no discovery path -}); - -// Must call obtainTokens() before other API calls -await xm.oauth.obtainTokens(); - -// Subsequent API calls are now oauth authenticated -await xm.groups.get(); -``` - -### Scenario 3: Pre-existing OAuth Tokens - -```typescript -const xm = new XmApi({ - hostname: 'https://company.xmatters.com', - accessToken: 'existing-token', - refreshToken: 'existing-refresh', - clientId: 'client-id', -}); - -// Already authenticated - can make API calls immediately -await xm.groups.get(); -``` From 8f0af442f4c6bebf267aac7cd16036ff88c92eff Mon Sep 17 00:00:00 2001 From: johan Date: Wed, 18 Jun 2025 21:55:22 -0700 Subject: [PATCH 058/101] Ensure valid hostname --- src/core/resource-client.test.ts | 9 +++ src/core/utils/config-validation.test.ts | 90 ++++++++++++++++++++---- src/core/utils/config-validation.ts | 19 ++++- 3 files changed, 101 insertions(+), 17 deletions(-) diff --git a/src/core/resource-client.test.ts b/src/core/resource-client.test.ts index d9c90f4..fbfea5a 100644 --- a/src/core/resource-client.test.ts +++ b/src/core/resource-client.test.ts @@ -23,11 +23,20 @@ class MockHttpClient { } } +// Create silent mock logger +const mockLogger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; + // Helper to create ResourceClient with mock dependencies function createResourceClientTestSetup(basePath: string) { const mockHttpClient = new MockHttpClient(); const requestHandler = new RequestHandler({ httpClient: mockHttpClient, + logger: mockLogger, hostname: 'https://test.xmatters.com', username: 'testuser', password: 'testpass', diff --git a/src/core/utils/config-validation.test.ts b/src/core/utils/config-validation.test.ts index 39950e2..04074a7 100644 --- a/src/core/utils/config-validation.test.ts +++ b/src/core/utils/config-validation.test.ts @@ -43,19 +43,79 @@ Deno.test('validateConfig - invalid hostname', () => { // @ts-ignore - Testing invalid property types expect(() => validateConfig({ hostname: 123 })).toThrow(XmApiError); // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ hostname: 123 })).toThrow('hostname must be a string'); + expect(() => validateConfig({ hostname: 123 })).toThrow( + 'Invalid config: hostname must be a valid xMatters hostname (*.xmatters.com or *.xmatters.com.au)', + ); // @ts-ignore - Testing invalid property types expect(() => validateConfig({ hostname: null })).toThrow(XmApiError); // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ hostname: null })).toThrow('hostname must be a string'); + expect(() => validateConfig({ hostname: null })).toThrow( + 'Invalid config: hostname must be a valid xMatters hostname (*.xmatters.com or *.xmatters.com.au)', + ); // @ts-ignore - Testing invalid property types expect(() => validateConfig({ hostname: undefined })).toThrow(XmApiError); // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ hostname: undefined })).toThrow('hostname must be a string'); + expect(() => validateConfig({ hostname: undefined })).toThrow( + 'Invalid config: hostname must be a valid xMatters hostname (*.xmatters.com or *.xmatters.com.au)', + ); +}); + +Deno.test('validateConfig - empty hostname', () => { + // @ts-ignore - Testing invalid configuration + expect(() => validateConfig({ hostname: '' })).toThrow(XmApiError); + // @ts-ignore - Testing invalid configuration + expect(() => validateConfig({ hostname: '' })).toThrow( + 'Invalid config: hostname must be a valid xMatters hostname (*.xmatters.com or *.xmatters.com.au)', + ); +}); + +Deno.test('validateConfig - invalid xMatters hostname', () => { + const invalidHostnames = [ + 'google.com', + 'example.org', + 'xmatters.com', // Missing subdomain + 'test.xmatters.co', // Wrong TLD + 'test.xmatters.net', + 'test.xmatters.com.uk', // Wrong country code + 'xmatters.com.au', // Missing subdomain + 'sub.domain.example.com', + 'localhost', + '192.168.1.1', + 'test.xmatters.comm', // Typo in domain + 'testxmatters.com', // Missing dot before xmatters + ]; + + invalidHostnames.forEach((hostname) => { + // @ts-ignore - Testing invalid configuration + expect(() => validateConfig({ hostname })).toThrow(XmApiError); + // @ts-ignore - Testing invalid configuration + expect(() => validateConfig({ hostname })).toThrow( + 'Invalid config: hostname must be a valid xMatters hostname (*.xmatters.com or *.xmatters.com.au)', + ); + }); +}); + +Deno.test('validateConfig - valid xMatters hostname', () => { + const validHostnames = [ + 'company.xmatters.com', + 'test.xmatters.com', + 'my-org.xmatters.com', + 'company.xmatters.com.au', + 'test.xmatters.com.au', + 'my-org.xmatters.com.au', + 'sub.domain.xmatters.com', + 'sub.domain.xmatters.com.au', + ]; + + validHostnames.forEach((hostname) => { + // Need valid auth config to pass full validation + const config = { hostname, username: 'user', password: 'pass' }; + expect(() => validateConfig(config)).not.toThrow(); + }); }); Deno.test('validateConfig - invalid maxRetries', () => { - const baseConfig = { hostname: 'test.com', username: 'user', password: 'pass' }; + const baseConfig = { hostname: 'test.xmatters.com', username: 'user', password: 'pass' }; // @ts-ignore - Testing invalid property types expect(() => validateConfig({ ...baseConfig, maxRetries: 'invalid' })).toThrow(XmApiError); // @ts-ignore - Testing invalid property types @@ -73,7 +133,7 @@ Deno.test('validateConfig - invalid maxRetries', () => { }); Deno.test('validateConfig - valid maxRetries', () => { - const baseConfig = { hostname: 'test.com', username: 'user', password: 'pass' }; + const baseConfig = { hostname: 'test.xmatters.com', username: 'user', password: 'pass' }; expect(() => validateConfig({ ...baseConfig, maxRetries: 0 })).not.toThrow(); expect(() => validateConfig({ ...baseConfig, maxRetries: 3 })).not.toThrow(); expect(() => validateConfig({ ...baseConfig, maxRetries: 10 })).not.toThrow(); @@ -81,7 +141,7 @@ Deno.test('validateConfig - valid maxRetries', () => { Deno.test('validateConfig - no auth method provided', () => { // @ts-ignore - Testing incomplete config - const config = { hostname: 'test.com' }; + const config = { hostname: 'test.xmatters.com' }; // @ts-ignore - Testing incomplete config expect(() => validateConfig(config)).toThrow(XmApiError); // @ts-ignore - Testing incomplete config @@ -93,7 +153,7 @@ Deno.test('validateConfig - no auth method provided', () => { Deno.test('validateConfig - multiple auth methods', () => { // @ts-ignore - Testing invalid config combination const config = { - hostname: 'test.com', + hostname: 'test.xmatters.com', username: 'user', password: 'pass', authorizationCode: 'code', @@ -106,7 +166,7 @@ Deno.test('validateConfig - multiple auth methods', () => { }); Deno.test('validateConfig - basic auth validation', () => { - const baseConfig = { hostname: 'test.com' }; + const baseConfig = { hostname: 'test.xmatters.com' }; // Invalid username types // @ts-ignore - Testing invalid property types expect(() => validateConfig({ ...baseConfig, username: 123, password: 'pass' })).toThrow( @@ -159,7 +219,7 @@ Deno.test('validateConfig - basic auth validation', () => { Deno.test('validateConfig - valid basic auth', () => { const config = { - hostname: 'test.com', + hostname: 'test.xmatters.com', username: 'user', password: 'pass', }; @@ -167,7 +227,7 @@ Deno.test('validateConfig - valid basic auth', () => { }); Deno.test('validateConfig - auth code validation', () => { - const baseConfig = { hostname: 'test.com' }; + const baseConfig = { hostname: 'test.xmatters.com' }; // Invalid authorizationCode types // @ts-ignore - Testing invalid property types expect(() => validateConfig({ ...baseConfig, authorizationCode: 123, clientId: 'client' })) @@ -229,7 +289,7 @@ Deno.test('validateConfig - auth code validation', () => { Deno.test('validateConfig - valid auth code', () => { const config = { - hostname: 'test.com', + hostname: 'test.xmatters.com', authorizationCode: 'code', clientId: 'client', }; @@ -243,7 +303,7 @@ Deno.test('validateConfig - valid auth code', () => { }); Deno.test('validateConfig - OAuth tokens validation', () => { - const baseConfig = { hostname: 'test.com' }; + const baseConfig = { hostname: 'test.xmatters.com' }; // Invalid accessToken types // @ts-ignore - Testing invalid property types expect(() => @@ -339,7 +399,7 @@ Deno.test('validateConfig - OAuth tokens validation', () => { Deno.test('validateConfig - valid OAuth tokens', () => { const config = { - hostname: 'test.com', + hostname: 'test.xmatters.com', accessToken: 'access', refreshToken: 'refresh', clientId: 'client', @@ -350,7 +410,7 @@ Deno.test('validateConfig - valid OAuth tokens', () => { Deno.test('validateConfig - edge cases', () => { // maxRetries undefined should be fine const config = { - hostname: 'test.com', + hostname: 'test.xmatters.com', username: 'user', password: 'pass', maxRetries: undefined, @@ -358,7 +418,7 @@ Deno.test('validateConfig - edge cases', () => { expect(() => validateConfig(config)).not.toThrow(); // clientSecret undefined should be fine const authCodeConfig = { - hostname: 'test.com', + hostname: 'test.xmatters.com', authorizationCode: 'code', clientId: 'client', clientSecret: undefined, diff --git a/src/core/utils/config-validation.ts b/src/core/utils/config-validation.ts index 92d8549..5764cd9 100644 --- a/src/core/utils/config-validation.ts +++ b/src/core/utils/config-validation.ts @@ -1,6 +1,15 @@ import { XmApiError } from '../errors.ts'; import type { XmApiConfig } from '../types/internal/config.ts'; +/** + * Validates that a hostname is a valid xMatters hostname. + * Valid hostnames must end with .xmatters.com or .xmatters.com.au + */ +function isValidXmHostname(hostname: string): boolean { + const validHostname = /^.*\.xmatters\.com(\.au)?$/i; + return validHostname.test(hostname); +} + /** * Validates that the config is in exactly one valid state. * Prevents invalid overlapping configurations and validates data types. @@ -14,8 +23,14 @@ export function validateConfig(config: XmApiConfig): void { throw new XmApiError('Invalid config: Expected object'); } // 2. Validate hostname - if (typeof config.hostname !== 'string') { - throw new XmApiError('Invalid config: hostname must be a string'); + if ( + typeof config.hostname !== 'string' || + !config.hostname || + !isValidXmHostname(config.hostname) + ) { + throw new XmApiError( + 'Invalid config: hostname must be a valid xMatters hostname (*.xmatters.com or *.xmatters.com.au)', + ); } // 3. Validate maxRetries if provided if (config.maxRetries !== undefined) { From db0727be28f2fedb7584fdac4413c4b6ae3ff0e4 Mon Sep 17 00:00:00 2001 From: johan Date: Wed, 18 Jun 2025 22:08:48 -0700 Subject: [PATCH 059/101] Reduce re-exported types list --- src/index.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index d01ee72..9dd8b64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,13 +26,9 @@ export class XmApi { } } -// Re-export types and errors -export * from './core/types/internal/config.ts'; -export * from './core/types/internal/http.ts'; -export * from './core/types/internal/oauth.ts'; -export * from './core/types/internal/auth-state.ts'; -export * from './core/types/endpoint/response.ts'; -export * from './core/types/endpoint/composers.ts'; -export * from './core/types/endpoint/params.ts'; -export * from './endpoints/groups/types.ts'; +// Re-export only the types consumers need to implement +// Dependency injection interfaces - consumers implement these +export type { Logger, TokenRefreshCallback } from './core/types/internal/config.ts'; +export type { HttpClient } from './core/types/internal/http.ts'; +// Export error class - consumers need to catch and handle these export { XmApiError } from './core/errors.ts'; From 6f1e43b45cba3fb181d87bbd218fca11983858c6 Mon Sep 17 00:00:00 2001 From: johan Date: Thu, 19 Jun 2025 00:27:25 -0700 Subject: [PATCH 060/101] Get started on unit test refactor --- src/core/test-utils.ts | 142 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 src/core/test-utils.ts diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts new file mode 100644 index 0000000..597493a --- /dev/null +++ b/src/core/test-utils.ts @@ -0,0 +1,142 @@ +/** + * @fileoverview Minimal test utilities for xMatters API library + * + * This module provides the bare essentials for testing: + * - Mock HTTP client that prevents network calls and tracks requests + * - Mock logger using Deno's stub functionality + * + * Testing Philosophy: + * - Keep it SIMPLE and RELIABLE + * - No HTTP requests should go over the wire during tests + * - Mock only at the HTTP client boundary + * - Test authors specify exact request-response pairs for predictability + */ + +import type { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; +import type { Logger } from './types/internal/config.ts'; +import { stub } from 'std/testing/mock.ts'; + +/** + * Request-response pair for testing + * Set up expected requests and their mocked responses. + */ +interface MockRequestResponse { + expectedRequest: Partial; + mockedResponse: HttpResponse; +} + +/** + * Mock HTTP client that prevents network calls during tests. + * Responses are consumed in FIFO order and validated against expected requests. + */ +export class MockHttpClient implements HttpClient { + private requestResponsePairs: MockRequestResponse[] = []; + private requestIndex = 0; + public requests: HttpRequest[] = []; + + send(request: HttpRequest): Promise { + this.requests.push(request); + if (this.requestIndex >= this.requestResponsePairs.length) { + throw new Error( + `MockHttpClient: Unexpected request #${ + this.requestIndex + 1 + }. Expected ${this.requestResponsePairs.length} requests total.`, + ); + } + const currentPair = this.requestResponsePairs[this.requestIndex]; + this.validateRequest(request, currentPair.expectedRequest, this.requestIndex); + const response = currentPair.mockedResponse; + this.requestIndex++; + return Promise.resolve(response); + } + + /** + * Set up expected requests and their mocked responses. + * Each actual request will be validated against the expected request in order. + * Responses are returned in the same order as the pairs are defined. + */ + setReqRes(pairs: MockRequestResponse[]): void { + this.requestResponsePairs = [...pairs]; // Copy to avoid external mutation + this.requestIndex = 0; + } + + /** + * Validates that all expected requests were made. + * Call this at the end of your test. + * Automatically resets the client for the next test. + */ + verifyAllRequestsMade(): void { + if (this.requestIndex < this.requestResponsePairs.length) { + throw new Error( + `MockHttpClient: Expected ${this.requestResponsePairs.length} requests, but only ${this.requestIndex} were made.`, + ); + } + // Auto-reset for next test + this.requests = []; + this.requestResponsePairs = []; + this.requestIndex = 0; + } + + private validateRequest( + actualRequest: HttpRequest, + expectedRequest: Partial, + requestNumber: number, + ): void { + const validationErrors: string[] = []; + if (expectedRequest.method && actualRequest.method !== expectedRequest.method) { + validationErrors.push( + `method: expected "${expectedRequest.method}", got "${actualRequest.method}"`, + ); + } + if (expectedRequest.url && actualRequest.url !== expectedRequest.url) { + validationErrors.push(`url: expected "${expectedRequest.url}", got "${actualRequest.url}"`); + } + if (expectedRequest.body !== undefined) { + const actualBodyJson = JSON.stringify(actualRequest.body); + const expectedBodyJson = JSON.stringify(expectedRequest.body); + if (actualBodyJson !== expectedBodyJson) { + validationErrors.push(`body: expected ${expectedBodyJson}, got ${actualBodyJson}`); + } + } + if (expectedRequest.headers) { + for (const [headerName, expectedValue] of Object.entries(expectedRequest.headers)) { + const actualValue = actualRequest.headers?.[headerName]; + if (actualValue !== expectedValue) { + validationErrors.push( + `header "${headerName}": expected "${expectedValue}", got "${ + actualValue || 'undefined' + }"`, + ); + } + } + } + if (validationErrors.length > 0) { + throw new Error( + `MockHttpClient: Request #${requestNumber + 1} validation failed:\n ${ + validationErrors.join('\n ') + }`, + ); + } + } +} + +/** + * Creates a silent mock logger with pre-configured stubs for call verification. + * Returns both the logger and the stubs for easy access. + */ +export function createMockLogger() { + const noop = () => {}; + const mockLogger: Logger = { + debug: noop, + info: noop, + warn: noop, + error: noop, + }; + return { + mockLogger, + debugSpy: stub(mockLogger, 'debug', noop), + infoSpy: stub(mockLogger, 'info', noop), + warnSpy: stub(mockLogger, 'warn', noop), + errorSpy: stub(mockLogger, 'error', noop), + }; +} From e9bea7a3d84387b0086d809897541b95d0573346 Mon Sep 17 00:00:00 2001 From: johan Date: Thu, 19 Jun 2025 12:00:37 -0700 Subject: [PATCH 061/101] Support loosely defined hostname in config - default to https Update test rig, make use of it in new integration test --- src/core/request-handler.ts | 6 +- src/core/test-utils.ts | 38 +- src/index.test.ts | 696 ++++++++++++++++++++++++++++++++++++ 3 files changed, 733 insertions(+), 7 deletions(-) create mode 100644 src/index.test.ts diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index fed2624..7a49299 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -72,7 +72,11 @@ export class RequestHandler { 'User-Agent': `xmas/${denoJson.version} (Deno)`, ...initialConfig.defaultHeaders, }; - this.requestBuilder = new RequestBuilder(initialConfig.hostname, headers); + // Ensure hostname includes protocol (only add https:// if not already present) + const baseUrl = initialConfig.hostname.startsWith('http') + ? initialConfig.hostname + : `https://${initialConfig.hostname}`; + this.requestBuilder = new RequestBuilder(baseUrl, headers); } async send( diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts index 597493a..7a81040 100644 --- a/src/core/test-utils.ts +++ b/src/core/test-utils.ts @@ -15,14 +15,17 @@ import type { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; import type { Logger } from './types/internal/config.ts'; import { stub } from 'std/testing/mock.ts'; +import { FakeTime } from 'std/testing/time.ts'; /** * Request-response pair for testing - * Set up expected requests and their mocked responses. + * Set up expected requests and their mocked responses or errors. */ interface MockRequestResponse { expectedRequest: Partial; - mockedResponse: HttpResponse; + mockedResponse?: HttpResponse; + /** If provided, the request will throw this error instead of returning a response */ + mockedError?: Error; } /** @@ -45,15 +48,22 @@ export class MockHttpClient implements HttpClient { } const currentPair = this.requestResponsePairs[this.requestIndex]; this.validateRequest(request, currentPair.expectedRequest, this.requestIndex); - const response = currentPair.mockedResponse; this.requestIndex++; - return Promise.resolve(response); + if (currentPair.mockedError) { + return Promise.reject(currentPair.mockedError); + } + if (!currentPair.mockedResponse) { + throw new Error( + `MockHttpClient: Request #${this.requestIndex} must have either mockedResponse or mockedError`, + ); + } + return Promise.resolve(currentPair.mockedResponse); } /** - * Set up expected requests and their mocked responses. + * Set up expected requests and their mocked responses or errors. * Each actual request will be validated against the expected request in order. - * Responses are returned in the same order as the pairs are defined. + * Responses/errors are returned in the same order as the pairs are defined. */ setReqRes(pairs: MockRequestResponse[]): void { this.requestResponsePairs = [...pairs]; // Copy to avoid external mutation @@ -140,3 +150,19 @@ export function createMockLogger() { errorSpy: stub(mockLogger, 'error', noop), }; } + +/** + * Utility function to simplify testing with FakeTime. + * Automatically manages FakeTime setup and cleanup. + * + * @param testFn - The test function to run with FakeTime control + * @returns A promise that resolves when the test completes + */ +export async function withFakeTime(testFn: (fakeTime: FakeTime) => Promise): Promise { + const fakeTime = new FakeTime(); + try { + await testFn(fakeTime); + } finally { + fakeTime.restore(); + } +} diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..fd0b2d4 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,696 @@ +import { expect } from 'std/expect/mod.ts'; +import { XmApi, XmApiError } from './index.ts'; +import { createMockLogger, MockHttpClient, withFakeTime } from './core/test-utils.ts'; + +// Shared mock HTTP client - resets after each test via verifyAllRequestsMade() +const mockHttpClient = new MockHttpClient(); + +Deno.test('XmApi - Basic Auth Integration', async () => { + const { mockLogger } = createMockLogger(); + + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + }); + + // Test a simple GET request + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups?limit=10', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', // base64 of testuser:testpass + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { count: 0, total: 0, data: [] }, + }, + }]); + + const response = await api.groups.get({ limit: 10 }); + expect(response.status).toBe(200); + expect(response.body.count).toBe(0); + + mockHttpClient.verifyAllRequestsMade(); +}); + +Deno.test('XmApi - OAuth Token Integration', async () => { + const { mockLogger } = createMockLogger(); + + const api = new XmApi({ + hostname: 'test.xmatters.com', + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + clientId: 'test-client-id', + httpClient: mockHttpClient, + logger: mockLogger, + }); + + // Test OAuth Bearer token is used + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Bearer test-access-token', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { count: 1, total: 1, data: [{ id: '123', targetName: 'Test Group' }] }, + }, + }]); + + const response = await api.groups.get(); + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(1); + + mockHttpClient.verifyAllRequestsMade(); +}); + +Deno.test('XmApi - Token Refresh on 401', async () => { + const { mockLogger } = createMockLogger(); + let tokenRefreshCalled = false; + let newAccessToken = ''; + let newRefreshToken = ''; + + const api = new XmApi({ + hostname: 'test.xmatters.com', + accessToken: 'expired-token', + refreshToken: 'valid-refresh-token', + clientId: 'test-client-id', + httpClient: mockHttpClient, + logger: mockLogger, + onTokenRefresh: (accessToken, refreshToken) => { + tokenRefreshCalled = true; + newAccessToken = accessToken; + newRefreshToken = refreshToken; + }, + }); + + mockHttpClient.setReqRes([ + // First request fails with 401 + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Bearer expired-token', + }, + }, + mockedResponse: { + status: 401, + headers: { 'Content-Type': 'application/json' }, + body: { error: 'Unauthorized' }, + }, + }, + // Token refresh request + { + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }, + body: 'grant_type=refresh_token&refresh_token=valid-refresh-token&client_id=test-client-id', + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'bearer', + expires_in: 3600, + }, + }, + }, + // Retry original request with new token + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Bearer new-access-token', + }, + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { count: 1, total: 1, data: [{ id: '123', targetName: 'Test Group' }] }, + }, + }, + ]); + + const response = await api.groups.get(); + expect(response.status).toBe(200); + expect(tokenRefreshCalled).toBe(true); + expect(newAccessToken).toBe('new-access-token'); + expect(newRefreshToken).toBe('new-refresh-token'); + + mockHttpClient.verifyAllRequestsMade(); +}); + +Deno.test('XmApi - Token Refresh Callback Error Handling', async () => { + const { mockLogger, warnSpy } = createMockLogger(); + + const api = new XmApi({ + hostname: 'test.xmatters.com', + accessToken: 'expired-token', + refreshToken: 'valid-refresh-token', + clientId: 'test-client-id', + httpClient: mockHttpClient, + logger: mockLogger, + onTokenRefresh: () => { + throw new Error('Callback error'); + }, + }); + + mockHttpClient.setReqRes([ + // First request fails with 401 + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Bearer expired-token', + }, + }, + mockedResponse: { + status: 401, + headers: { 'Content-Type': 'application/json' }, + body: { error: 'Unauthorized' }, + }, + }, + // Token refresh request + { + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'bearer', + expires_in: 3600, + }, + }, + }, + // Retry original request with new token + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Bearer new-access-token', + }, + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { count: 0, total: 0, data: [] }, + }, + }, + ]); + + // Should not throw despite callback error + const response = await api.groups.get(); + expect(response.status).toBe(200); + + // Should log warning about callback error + expect(warnSpy.calls).toHaveLength(1); + expect(warnSpy.calls[0].args[0]).toContain('Error in onTokenRefresh callback'); + + mockHttpClient.verifyAllRequestsMade(); +}); + +Deno.test('XmApi - Retry Logic for 429 Rate Limit', async () => { + return await withFakeTime(async (fakeTime) => { + const { mockLogger, debugSpy } = createMockLogger(); + + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + maxRetries: 2, + }); + + mockHttpClient.setReqRes([ + // First request fails with 429 + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + }, + mockedResponse: { + status: 429, + headers: { 'Retry-After': '1' }, // 1 second + body: { error: 'Rate limit exceeded' }, + }, + }, + // Second request also fails with 429 + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + }, + mockedResponse: { + status: 429, + headers: { 'Retry-After': '1' }, + body: { error: 'Rate limit exceeded' }, + }, + }, + // Third request succeeds + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { count: 0, total: 0, data: [] }, + }, + }, + ]); + + // Start the request but don't await it yet + const requestPromise = api.groups.get(); + + // Advance time to process the retries + await fakeTime.nextAsync(); // First request + first retry + await fakeTime.nextAsync(); // Second retry + final success + + const response = await requestPromise; + expect(response.status).toBe(200); + + // Should log retry attempts + const debugCalls = debugSpy.calls.filter((call) => + call.args[0].includes('Request failed with status 429') + ); + expect(debugCalls).toHaveLength(2); + + mockHttpClient.verifyAllRequestsMade(); + }); +}); + +Deno.test('XmApi - Retry Logic for 500 Server Error', async () => { + return await withFakeTime(async (fakeTime) => { + const { mockLogger } = createMockLogger(); + + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + maxRetries: 1, + }); + + mockHttpClient.setReqRes([ + // First request fails with 500 + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + }, + mockedResponse: { + status: 500, + headers: { 'Content-Type': 'application/json' }, + body: { error: 'Internal Server Error' }, + }, + }, + // Second request succeeds + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { count: 0, total: 0, data: [] }, + }, + }, + ]); + + // Start the request but don't await it yet + const requestPromise = api.groups.get(); + + // Advance time to process the retry + await fakeTime.nextAsync(); // First request + retry with exponential backoff + + const response = await requestPromise; + expect(response.status).toBe(200); + + mockHttpClient.verifyAllRequestsMade(); + }); +}); + +Deno.test('XmApi - Max Retries Exceeded', async () => { + return await withFakeTime(async (fakeTime) => { + const { mockLogger } = createMockLogger(); + + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + maxRetries: 1, + }); + + mockHttpClient.setReqRes([ + // First request fails with 500 + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + }, + mockedResponse: { + status: 500, + headers: { 'Content-Type': 'application/json' }, + body: { reason: 'Internal Server Error' }, + }, + }, + // Second request also fails with 500 + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + }, + mockedResponse: { + status: 500, + headers: { 'Content-Type': 'application/json' }, + body: { reason: 'Internal Server Error' }, + }, + }, + ]); + + // Start the request but don't await it yet + const requestPromise = api.groups.get().catch((error) => error); + + // Advance time to process all retry attempts + await fakeTime.nextAsync(); // First request + first retry (both fail) + + const error = await requestPromise; + expect(error).toBeInstanceOf(XmApiError); + const apiError = error as XmApiError; + // Should throw with the extracted error message from the response + expect(apiError.message).toBe('Internal Server Error'); + expect(apiError.response?.status).toBe(500); + + mockHttpClient.verifyAllRequestsMade(); + }); +}); + +Deno.test('XmApi - Error Response Structure', async () => { + const { mockLogger } = createMockLogger(); + + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + }); + + const errorResponse = { + status: 404, + headers: { 'Content-Type': 'application/json' }, + body: { error: 'Group not found', code: 'GROUP_NOT_FOUND' }, + }; + + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups/nonexistent', + }, + mockedResponse: errorResponse, + }]); + + try { + await api.groups.getById('nonexistent'); + throw new Error('Should have thrown XmApiError'); + } catch (error) { + expect(error).toBeInstanceOf(XmApiError); + const apiError = error as XmApiError; + expect(apiError.response).toBeDefined(); + expect(apiError.response?.status).toBe(404); + expect(apiError.response?.body).toEqual({ error: 'Group not found', code: 'GROUP_NOT_FOUND' }); + expect(apiError.response?.headers).toEqual({ 'Content-Type': 'application/json' }); + } + + mockHttpClient.verifyAllRequestsMade(); +}); + +Deno.test('XmApi - Network Error Handling', async () => { + const { mockLogger } = createMockLogger(); + + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + }); + + // Use mockedError to simulate network connection failure + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + }, + mockedError: new Error('Network connection failed'), + }]); + + try { + await api.groups.get(); + throw new Error('Should have thrown XmApiError'); + } catch (error) { + expect(error).toBeInstanceOf(XmApiError); + const apiError = error as XmApiError; + expect(apiError.message).toBe('Request failed'); + expect(apiError.response).toBeNull(); + expect((apiError.cause as Error)?.message).toBe('Network connection failed'); + } + + mockHttpClient.verifyAllRequestsMade(); +}); + +Deno.test('XmApi - Custom Headers Integration', async () => { + const { mockLogger } = createMockLogger(); + + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + defaultHeaders: { + 'X-Custom-Header': 'custom-value', + 'X-Client-Version': '1.0.0', + }, + }); + + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Custom-Header': 'custom-value', + 'X-Client-Version': '1.0.0', + }, + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { count: 0, total: 0, data: [] }, + }, + }]); + + const response = await api.groups.get(); + expect(response.status).toBe(200); + + mockHttpClient.verifyAllRequestsMade(); +}); + +Deno.test('XmApi - OAuth Token Acquisition', async () => { + const { mockLogger } = createMockLogger(); + let tokenRefreshCalled = false; + + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + onTokenRefresh: (accessToken, refreshToken) => { + tokenRefreshCalled = true; + expect(accessToken).toBe('obtained-access-token'); + expect(refreshToken).toBe('obtained-refresh-token'); + }, + }); + + // We'll validate the URL params more flexibly since URLSearchParams order can vary + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }, + // Don't validate exact body order since URLSearchParams might reorder + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { + access_token: 'obtained-access-token', + refresh_token: 'obtained-refresh-token', + token_type: 'bearer', + expires_in: 3600, + }, + }, + }]); + + const response = await api.oauth.obtainTokens({ clientId: 'test-client' }); + expect(response.status).toBe(200); + expect(response.body.access_token).toBe('obtained-access-token'); + expect(tokenRefreshCalled).toBe(true); + + // Validate that the request body contains the expected parameters + const request = mockHttpClient.requests[0]; + const bodyString = request.body as string; + expect(bodyString).toContain('grant_type=password'); + expect(bodyString).toContain('username=testuser'); + expect(bodyString).toContain('password=testpass'); + expect(bodyString).toContain('client_id=test-client'); + + mockHttpClient.verifyAllRequestsMade(); +}); + +Deno.test('XmApi - User-Agent Header', async () => { + const { mockLogger } = createMockLogger(); + + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + }); + + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'User-Agent': 'xmas/0.0.1 (Deno)', // Should match version in deno.json + }, + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { count: 0, total: 0, data: [] }, + }, + }]); + + const response = await api.groups.get(); + expect(response.status).toBe(200); + + mockHttpClient.verifyAllRequestsMade(); +}); + +Deno.test('XmApi - Logging Integration', async () => { + const { mockLogger, debugSpy } = createMockLogger(); + + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + }); + + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { count: 0, total: 0, data: [] }, + }, + }]); + + await api.groups.get(); + + // Should log request and response + const requestLog = debugSpy.calls.find((call) => + call.args[0].includes('--> GET https://test.xmatters.com/api/xm/1/groups') + ); + const responseLog = debugSpy.calls.find((call) => call.args[0].includes('<-- 200')); + + expect(requestLog).toBeDefined(); + expect(responseLog).toBeDefined(); + + mockHttpClient.verifyAllRequestsMade(); +}); + +/* + +1. Authentication Integration: + + Basic Auth with proper header encoding + + OAuth Bearer token authentication + + Token refresh on 401 responses + + Token refresh callback handling with error safety + +2. HTTP Client Integration: + + Request building and sending through injected HTTP client + + Custom headers merging (default + per-request) + + User-Agent header generation from deno.json version + +3. Retry Logic: + + 429 rate limit retries with Retry-After header respect + + 500 server error retries with exponential backoff + + Maximum retry attempts enforcement + + Proper error handling after max retries exceeded + +4. Logging Integration: + + Request/response logging through injected logger + + Debug logging for retry attempts + + Warning logging for token refresh callback errors + +5. Error Handling: + + Proper XmApiError instances with response details + + Network error handling with cause preservation + + Consistent error structure for consumers + +6. OAuth Token Management: + + Token acquisition from basic auth credentials + + Token refresh callback execution + + Error handling in token refresh callbacks + +*/ From a94e8a76319fbbc51fb6aa1b6c6321fb25db5dc5 Mon Sep 17 00:00:00 2001 From: johan Date: Thu, 19 Jun 2025 16:03:28 -0700 Subject: [PATCH 062/101] Update MockHttpClient to work with `expect` to throw better errors --- src/core/test-utils.ts | 43 +++------------------ src/index.test.ts | 88 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 38 deletions(-) diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts index 7a81040..37a6b6f 100644 --- a/src/core/test-utils.ts +++ b/src/core/test-utils.ts @@ -16,6 +16,7 @@ import type { HttpClient, HttpRequest, HttpResponse } from './types/internal/htt import type { Logger } from './types/internal/config.ts'; import { stub } from 'std/testing/mock.ts'; import { FakeTime } from 'std/testing/time.ts'; +import { expect } from 'std/expect/mod.ts'; /** * Request-response pair for testing @@ -47,7 +48,7 @@ export class MockHttpClient implements HttpClient { ); } const currentPair = this.requestResponsePairs[this.requestIndex]; - this.validateRequest(request, currentPair.expectedRequest, this.requestIndex); + this.validateRequest(request, currentPair.expectedRequest); this.requestIndex++; if (currentPair.mockedError) { return Promise.reject(currentPair.mockedError); @@ -90,43 +91,11 @@ export class MockHttpClient implements HttpClient { private validateRequest( actualRequest: HttpRequest, expectedRequest: Partial, - requestNumber: number, ): void { - const validationErrors: string[] = []; - if (expectedRequest.method && actualRequest.method !== expectedRequest.method) { - validationErrors.push( - `method: expected "${expectedRequest.method}", got "${actualRequest.method}"`, - ); - } - if (expectedRequest.url && actualRequest.url !== expectedRequest.url) { - validationErrors.push(`url: expected "${expectedRequest.url}", got "${actualRequest.url}"`); - } - if (expectedRequest.body !== undefined) { - const actualBodyJson = JSON.stringify(actualRequest.body); - const expectedBodyJson = JSON.stringify(expectedRequest.body); - if (actualBodyJson !== expectedBodyJson) { - validationErrors.push(`body: expected ${expectedBodyJson}, got ${actualBodyJson}`); - } - } - if (expectedRequest.headers) { - for (const [headerName, expectedValue] of Object.entries(expectedRequest.headers)) { - const actualValue = actualRequest.headers?.[headerName]; - if (actualValue !== expectedValue) { - validationErrors.push( - `header "${headerName}": expected "${expectedValue}", got "${ - actualValue || 'undefined' - }"`, - ); - } - } - } - if (validationErrors.length > 0) { - throw new Error( - `MockHttpClient: Request #${requestNumber + 1} validation failed:\n ${ - validationErrors.join('\n ') - }`, - ); - } + expect(actualRequest.method).toBe(expectedRequest.method); + expect(actualRequest.url).toBe(expectedRequest.url); + expect(actualRequest.body).toBe(expectedRequest.body); + expect(actualRequest.headers).toEqual(expectedRequest.headers); } } diff --git a/src/index.test.ts b/src/index.test.ts index fd0b2d4..acfd6e2 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -25,6 +25,7 @@ Deno.test('XmApi - Basic Auth Integration', async () => { 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', // base64 of testuser:testpass 'Content-Type': 'application/json', 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, mockedResponse: { @@ -62,6 +63,7 @@ Deno.test('XmApi - OAuth Token Integration', async () => { 'Authorization': 'Bearer test-access-token', 'Content-Type': 'application/json', 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, mockedResponse: { @@ -106,6 +108,9 @@ Deno.test('XmApi - Token Refresh on 401', async () => { url: 'https://test.xmatters.com/api/xm/1/groups', headers: { 'Authorization': 'Bearer expired-token', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, mockedResponse: { @@ -122,6 +127,7 @@ Deno.test('XmApi - Token Refresh on 401', async () => { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', }, body: 'grant_type=refresh_token&refresh_token=valid-refresh-token&client_id=test-client-id', }, @@ -143,6 +149,9 @@ Deno.test('XmApi - Token Refresh on 401', async () => { url: 'https://test.xmatters.com/api/xm/1/groups', headers: { 'Authorization': 'Bearer new-access-token', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, mockedResponse: { @@ -185,6 +194,9 @@ Deno.test('XmApi - Token Refresh Callback Error Handling', async () => { url: 'https://test.xmatters.com/api/xm/1/groups', headers: { 'Authorization': 'Bearer expired-token', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, mockedResponse: { @@ -198,6 +210,12 @@ Deno.test('XmApi - Token Refresh Callback Error Handling', async () => { expectedRequest: { method: 'POST', url: 'https://test.xmatters.com/api/xm/1/oauth2/token', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + body: 'grant_type=refresh_token&refresh_token=valid-refresh-token&client_id=test-client-id', }, mockedResponse: { status: 200, @@ -217,6 +235,9 @@ Deno.test('XmApi - Token Refresh Callback Error Handling', async () => { url: 'https://test.xmatters.com/api/xm/1/groups', headers: { 'Authorization': 'Bearer new-access-token', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, mockedResponse: { @@ -257,6 +278,12 @@ Deno.test('XmApi - Retry Logic for 429 Rate Limit', async () => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, }, mockedResponse: { status: 429, @@ -269,6 +296,12 @@ Deno.test('XmApi - Retry Logic for 429 Rate Limit', async () => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, }, mockedResponse: { status: 429, @@ -281,6 +314,12 @@ Deno.test('XmApi - Retry Logic for 429 Rate Limit', async () => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, }, mockedResponse: { status: 200, @@ -329,6 +368,12 @@ Deno.test('XmApi - Retry Logic for 500 Server Error', async () => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, }, mockedResponse: { status: 500, @@ -341,6 +386,12 @@ Deno.test('XmApi - Retry Logic for 500 Server Error', async () => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, }, mockedResponse: { status: 200, @@ -382,6 +433,12 @@ Deno.test('XmApi - Max Retries Exceeded', async () => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, }, mockedResponse: { status: 500, @@ -394,6 +451,12 @@ Deno.test('XmApi - Max Retries Exceeded', async () => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, }, mockedResponse: { status: 500, @@ -441,6 +504,12 @@ Deno.test('XmApi - Error Response Structure', async () => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups/nonexistent', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, }, mockedResponse: errorResponse, }]); @@ -476,6 +545,12 @@ Deno.test('XmApi - Network Error Handling', async () => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, }, mockedError: new Error('Network connection failed'), }]); @@ -517,6 +592,7 @@ Deno.test('XmApi - Custom Headers Integration', async () => { 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', 'Content-Type': 'application/json', 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', 'X-Custom-Header': 'custom-value', 'X-Client-Version': '1.0.0', }, @@ -559,8 +635,9 @@ Deno.test('XmApi - OAuth Token Acquisition', async () => { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', }, - // Don't validate exact body order since URLSearchParams might reorder + body: 'grant_type=password&client_id=test-client&username=testuser&password=testpass', }, mockedResponse: { status: 200, @@ -606,6 +683,9 @@ Deno.test('XmApi - User-Agent Header', async () => { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', // Should match version in deno.json }, }, @@ -637,6 +717,12 @@ Deno.test('XmApi - Logging Integration', async () => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, }, mockedResponse: { status: 200, From d08b2022709cb5d9a8a6ce515638bf39e3e15e76 Mon Sep 17 00:00:00 2001 From: johan Date: Thu, 19 Jun 2025 17:58:34 -0700 Subject: [PATCH 063/101] Allow consumers to override headers --- src/core/defaults/http-client.ts | 4 +-- src/core/errors.ts | 4 ++- src/core/request-builder.test.ts | 3 ++- src/core/request-builder.ts | 8 +++--- src/core/request-handler.ts | 4 +-- src/core/types/internal/config.ts | 3 ++- src/core/types/internal/http-methods.ts | 6 +++-- src/core/types/internal/http.ts | 9 +++++-- src/endpoints/groups/index.ts | 33 +++++++++++++++++++------ 9 files changed, 51 insertions(+), 23 deletions(-) diff --git a/src/core/defaults/http-client.ts b/src/core/defaults/http-client.ts index c41872d..4dd6865 100644 --- a/src/core/defaults/http-client.ts +++ b/src/core/defaults/http-client.ts @@ -1,4 +1,4 @@ -import type { HttpClient, HttpRequest, HttpResponse } from '../types/internal/http.ts'; +import type { Headers, HttpClient, HttpRequest, HttpResponse } from '../types/internal/http.ts'; export class DefaultHttpClient implements HttpClient { async send(request: HttpRequest): Promise { @@ -15,7 +15,7 @@ export class DefaultHttpClient implements HttpClient { body: serializedRequestBody, }); - const headers: Record = {}; + const headers: Headers = {}; response.headers.forEach((value, key) => { headers[key.toLowerCase()] = value; }); diff --git a/src/core/errors.ts b/src/core/errors.ts index bd0e8f0..ff81478 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -1,3 +1,5 @@ +import type { Headers } from './types/internal/http.ts'; + /** * Base class for all errors thrown by the xMatters API client. * Contains information about the failed request and response. @@ -26,7 +28,7 @@ export class XmApiError extends Error { /** The HTTP status code that triggered this error */ status: number; /** Response headers that may contain additional error context */ - headers: Record; + headers: Headers; } | null, public override readonly cause?: unknown, ) { diff --git a/src/core/request-builder.test.ts b/src/core/request-builder.test.ts index f421576..f0696cd 100644 --- a/src/core/request-builder.test.ts +++ b/src/core/request-builder.test.ts @@ -1,11 +1,12 @@ import { expect } from 'std/expect/mod.ts'; import { RequestBuilder, type RequestBuildOptions } from './request-builder.ts'; +import type { Headers } from './types/internal/http.ts'; import { XmApiError } from './errors.ts'; // Test helper to create RequestBuilder with standard configuration function createRequestBuilderTestSetup(options: { hostname?: string; - defaultHeaders?: Record; + defaultHeaders?: Headers; } = {}) { const { hostname = 'https://example.xmatters.com', diff --git a/src/core/request-builder.ts b/src/core/request-builder.ts index 675f7a0..61abfaf 100644 --- a/src/core/request-builder.ts +++ b/src/core/request-builder.ts @@ -1,4 +1,4 @@ -import type { HttpRequest } from './types/internal/http.ts'; +import type { Headers, HttpRequest } from './types/internal/http.ts'; import { XmApiError } from './errors.ts'; /** @@ -23,7 +23,7 @@ export interface RequestBuildOptions { /** The HTTP method to use for the request */ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; /** Optional headers to send with the request */ - headers?: Record; + headers?: Headers; /** Optional query parameters to include in the URL */ query?: Record; /** Optional request body */ @@ -39,7 +39,7 @@ export class RequestBuilder { constructor( private readonly baseUrl: string, - private readonly defaultHeaders: Record = {}, + private readonly defaultHeaders: Headers = {}, ) {} build(options: RequestBuildOptions): HttpRequest { @@ -67,7 +67,7 @@ export class RequestBuilder { }); } // Build headers by merging default headers with request-specific headers - const headers: Record = { ...this.defaultHeaders, ...options.headers }; + const headers: Headers = { ...this.defaultHeaders, ...options.headers }; const builtRequest: HttpRequest = { method: options.method || 'GET', url: url.toString(), diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index 7a49299..d932563 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -1,4 +1,4 @@ -import type { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; +import type { Headers, HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; import { isAuthCodeConfig, isBasicAuthConfig, @@ -66,7 +66,7 @@ export class RequestHandler { throw new XmApiError('Invalid configuration type'); } // Create request builder with immutable properties - const headers: Record = { + const headers: Headers = { 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': `xmas/${denoJson.version} (Deno)`, diff --git a/src/core/types/internal/config.ts b/src/core/types/internal/config.ts index a7050ff..176356f 100644 --- a/src/core/types/internal/config.ts +++ b/src/core/types/internal/config.ts @@ -4,6 +4,7 @@ */ import type { HttpClient } from './http.ts'; +import type { Headers } from './http.ts'; /** * Interface that loggers must implement to be used with this library. @@ -32,7 +33,7 @@ export interface XmApiBaseConfig { hostname: string; httpClient?: HttpClient; logger?: Logger; - defaultHeaders?: Record; + defaultHeaders?: Headers; maxRetries?: number; onTokenRefresh?: TokenRefreshCallback; } diff --git a/src/core/types/internal/http-methods.ts b/src/core/types/internal/http-methods.ts index 564b696..1abd861 100644 --- a/src/core/types/internal/http-methods.ts +++ b/src/core/types/internal/http-methods.ts @@ -3,14 +3,16 @@ * These types define the shape of options passed to HTTP method calls. */ +import type { Headers } from './http.ts'; + /** * Base interface for all HTTP method options */ -interface HttpMethodBaseOptions { +export interface HttpMethodBaseOptions { /** The path portion of the URL, relative to the API version path */ path: string; /** Optional headers to send with the request */ - headers?: Record; + headers?: Headers; /** Whether to skip adding authentication headers */ skipAuth?: boolean; } diff --git a/src/core/types/internal/http.ts b/src/core/types/internal/http.ts index e03e5d9..af1a92a 100644 --- a/src/core/types/internal/http.ts +++ b/src/core/types/internal/http.ts @@ -3,6 +3,11 @@ * These types define the shape of requests and responses handled by the HTTP layer. */ +/** + * HTTP headers as key-value pairs + */ +export type Headers = Record; + /** * Represents an HTTP response from the xMatters API. * @template T The expected type of the response body @@ -13,7 +18,7 @@ export interface HttpResponse { /** The HTTP status code */ status: number; /** Response headers */ - headers: Record; + headers: Headers; } /** @@ -27,7 +32,7 @@ export interface HttpRequest { /** The complete, fully-qualified URL ready for the HTTP client to use */ url: string; /** Headers to send with the request (includes auth, content-type, etc.) */ - headers?: Record; + headers?: Headers; /** Optional request body (injected HTTP client should handle serialization) */ body?: unknown; /** Current retry attempt number (for logging/debugging by HTTP clients) */ diff --git a/src/endpoints/groups/index.ts b/src/endpoints/groups/index.ts index f3a53b8..59aebaf 100644 --- a/src/endpoints/groups/index.ts +++ b/src/endpoints/groups/index.ts @@ -1,6 +1,7 @@ import { ResourceClient } from '../../core/resource-client.ts'; import type { RequestHandler } from '../../core/request-handler.ts'; import type { HttpResponse } from '../../core/types/internal/http.ts'; +import type { HttpMethodBaseOptions } from '../../core/types/internal/http-methods.ts'; import type { EmptyHttpResponse, PaginatedHttpResponse, @@ -23,43 +24,59 @@ export class GroupsEndpoint { * The results can be filtered and paginated using the params object. * * @param params Optional parameters to filter and paginate the results + * @param overrides Optional request overrides like custom headers * @returns The HTTP response containing a paginated list of groups * @throws {XmApiError} If the request fails */ - get(params?: GetGroupsParams): Promise> { - return this.http.get({ query: params }); + get( + params?: GetGroupsParams, + overrides?: Pick, + ): Promise> { + return this.http.get({ ...overrides, query: params }); } /** * Get a group by ID * * @param id The ID of the group to retrieve + * @param overrides Optional request overrides like custom headers * @returns The HTTP response containing the group * @throws {XmApiError} If the request fails */ - getById(id: string): Promise> { - return this.http.get({ path: id }); + getById( + id: string, + overrides?: Pick, + ): Promise> { + return this.http.get({ ...overrides, path: id }); } /** * Create a new group or update an existing one * * @param group The group to create or update + * @param overrides Optional request overrides like custom headers * @returns The HTTP response containing the created or updated group * @throws {XmApiError} If the request fails */ - save(group: Partial): Promise> { - return this.http.post({ body: group }); + save( + group: Partial, + overrides?: Pick, + ): Promise> { + return this.http.post({ ...overrides, body: group }); } /** * Delete a group by ID * * @param id The ID of the group to delete + * @param overrides Optional request overrides like custom headers * @returns The HTTP response * @throws {XmApiError} If the request fails */ - delete(id: string): Promise { - return this.http.delete({ path: id }); + delete( + id: string, + overrides?: Pick, + ): Promise { + return this.http.delete({ ...overrides, path: id }); } } From c2611d90573f4c51a2ad10af6b528823a0de99d6 Mon Sep 17 00:00:00 2001 From: johan Date: Thu, 19 Jun 2025 23:31:52 -0700 Subject: [PATCH 064/101] Refine endpoints types system --- sandbox/index.ts | 6 ++-- src/core/request-builder.test.ts | 49 ++++++++++++++++++++++++++++ src/core/request-builder.ts | 7 +++- src/core/resource-client.ts | 4 +-- src/core/types/endpoint/composers.ts | 2 +- src/endpoints/groups/index.test.ts | 25 +++++++------- src/endpoints/groups/index.ts | 36 ++++++++++---------- src/endpoints/groups/types.ts | 26 +++++++++++++-- src/index.test.ts | 4 +-- 9 files changed, 118 insertions(+), 41 deletions(-) diff --git a/sandbox/index.ts b/sandbox/index.ts index 3b765c9..655a0ed 100644 --- a/sandbox/index.ts +++ b/sandbox/index.ts @@ -10,7 +10,7 @@ async function testBasicAuthOnly() { } try { const xm = new XmApi(config.basicAuth); - const response = await xm.groups.get({ limit: 1 }); + const response = await xm.groups.get({ query: { limit: 1 } }); console.log('[SUCCESS] Basic Auth Only:', response.status, response.body); } catch (err) { printError('[ERROR] Basic Auth Only:', err); @@ -38,7 +38,7 @@ async function testOauthViaBasicAuthWithExplicitClientId() { // Save tokens for scenario 4 lastAccessToken = tokenResp.body.access_token; lastRefreshToken = tokenResp.body.refresh_token; - const response = await xm.groups.get({ limit: 1 }); + const response = await xm.groups.get({ query: { limit: 1 } }); console.log( '[SUCCESS-2] Basic Auth (explicit clientId): API Call Response:', response.status, @@ -79,7 +79,7 @@ async function testPreExistingOAuthTokens() { } try { const xm = new XmApi({ hostname, accessToken, refreshToken, clientId }); - const response = await xm.groups.get({ limit: 1 }); + const response = await xm.groups.get({ query: { limit: 1 } }); console.log( '[SUCCESS] Pre-existing OAuth Tokens: API Call Response:', response.status, diff --git a/src/core/request-builder.test.ts b/src/core/request-builder.test.ts index f0696cd..1fee631 100644 --- a/src/core/request-builder.test.ts +++ b/src/core/request-builder.test.ts @@ -289,4 +289,53 @@ Deno.test('RequestBuilder', async (t) => { expect(request.url).toContain('/api/xm/1'); // Should contain API version expect(request.url).toContain('example.xmatters.com'); // Should contain configured hostname }); + + await t.step('handles array query parameters by joining with commas', () => { + const { builder } = createRequestBuilderTestSetup(); + const options: RequestBuildOptions = { + path: '/groups/123', + query: { + embed: ['supervisors', 'services', 'observers'], + tags: ['urgent', 'critical'], + single: 'value', + }, + }; + const request = builder.build(options); + const url = new URL(request.url); + expect(url.searchParams.get('embed')).toBe('supervisors,services,observers'); + expect(url.searchParams.get('tags')).toBe('urgent,critical'); + expect(url.searchParams.get('single')).toBe('value'); + }); + + await t.step('handles empty arrays gracefully', () => { + const { builder } = createRequestBuilderTestSetup(); + const options: RequestBuildOptions = { + path: '/groups', + query: { + embed: [], + normal: 'value', + }, + }; + const request = builder.build(options); + const url = new URL(request.url); + expect(url.searchParams.get('embed')).toBe(''); + expect(url.searchParams.get('normal')).toBe('value'); + }); + + await t.step('handles mixed array types by converting to strings', () => { + const { builder } = createRequestBuilderTestSetup(); + const options: RequestBuildOptions = { + path: '/items', + query: { + ids: [1, 2, 3], + flags: [true, false], + mixed: ['string', 42, true], + }, + }; + const request = builder.build(options); + const url = new URL(request.url); + expect(url.searchParams.get('ids')).toBe('1,2,3'); + expect(url.searchParams.get('flags')).toBe('true,false'); + expect(url.searchParams.get('mixed')).toBe('string,42,true'); + }); }); diff --git a/src/core/request-builder.ts b/src/core/request-builder.ts index 61abfaf..e35e53a 100644 --- a/src/core/request-builder.ts +++ b/src/core/request-builder.ts @@ -62,7 +62,12 @@ export class RequestBuilder { if (options.query) { Object.entries(options.query).forEach(([key, value]) => { if (value !== undefined && value !== null) { - url.searchParams.set(key, String(value)); + if (Array.isArray(value)) { + // Handle arrays by joining with commas + url.searchParams.set(key, value.map(String).join(',')); + } else { + url.searchParams.set(key, String(value)); + } } }); } diff --git a/src/core/resource-client.ts b/src/core/resource-client.ts index 9844376..df00c66 100644 --- a/src/core/resource-client.ts +++ b/src/core/resource-client.ts @@ -33,10 +33,10 @@ export class ResourceClient { return `${this.basePath}/${cleanPath}`; } - get(options: Omit & { path?: string }) { + get(options?: Omit & { path?: string }) { return this.http.get({ ...options, - path: this.buildPath(options.path), + path: this.buildPath(options?.path), }); } diff --git a/src/core/types/endpoint/composers.ts b/src/core/types/endpoint/composers.ts index afd903f..82a5863 100644 --- a/src/core/types/endpoint/composers.ts +++ b/src/core/types/endpoint/composers.ts @@ -89,7 +89,7 @@ export type WithSort = Record> * return this.http.get({ path: '/users', query: params }); * } * - * async getById(id: string): GetUserResponse { + * async getByIdentifier(id: string): GetUserResponse { * return this.http.get({ path: `/${id}` }); * } * } diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index dbf91ca..a51b5f5 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -169,7 +169,7 @@ Deno.test('GroupsEndpoint', async (t) => { const { mockHttpClient, endpoint } = createEndpointTestSetup(); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); try { - const response = await endpoint.get({ limit: 10, offset: 20 }); + const response = await endpoint.get({ query: { limit: 10, offset: 20 } }); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); expect(sentRequest.url).toBe( @@ -189,7 +189,7 @@ Deno.test('GroupsEndpoint', async (t) => { const { mockHttpClient, endpoint } = createEndpointTestSetup(); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); try { - const response = await endpoint.get({ search: 'oncall', limit: 5 }); + const response = await endpoint.get({ query: { search: 'oncall', limit: 5 } }); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); expect(sentRequest.url).toBe( @@ -205,11 +205,11 @@ Deno.test('GroupsEndpoint', async (t) => { } }); - await t.step('getById() - sends correct HTTP request', async () => { + await t.step('getByIdentifier() - sends correct HTTP request', async () => { const { mockHttpClient, endpoint } = createEndpointTestSetup(); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockSingleGroupResponse)); try { - const response = await endpoint.getById('test-group-123'); + const response = await endpoint.getByIdentifier('test-group-123'); expect(sendStub.calls.length).toBe(1); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); @@ -299,7 +299,7 @@ Deno.test('GroupsEndpoint', async (t) => { try { let thrownError: unknown; try { - await endpoint.getById('non-existent-group'); + await endpoint.getByIdentifier('non-existent-group'); } catch (error) { thrownError = error; } @@ -414,14 +414,15 @@ Deno.test('GroupsEndpoint', async (t) => { await t.step('get() with all possible parameters', async () => { const { mockHttpClient, endpoint } = createEndpointTestSetup(); const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); - const params = { - limit: 25, - offset: 50, - search: 'test search', - // Add other params that might exist in GetGroupsParams - }; try { - const response = await endpoint.get(params); + const response = await endpoint.get({ + query: { + limit: 25, + offset: 50, + search: 'test search', + // Add other params that might exist in GetGroupsParams + }, + }); expect(sendStub.calls.length).toBe(1); const sentRequest: HttpRequest = sendStub.calls[0].args[0]; expect(sentRequest.method).toBe('GET'); diff --git a/src/endpoints/groups/index.ts b/src/endpoints/groups/index.ts index 59aebaf..b259e06 100644 --- a/src/endpoints/groups/index.ts +++ b/src/endpoints/groups/index.ts @@ -1,12 +1,16 @@ import { ResourceClient } from '../../core/resource-client.ts'; import type { RequestHandler } from '../../core/request-handler.ts'; import type { HttpResponse } from '../../core/types/internal/http.ts'; -import type { HttpMethodBaseOptions } from '../../core/types/internal/http-methods.ts'; +import type { + DeleteOptions, + GetOptions, + RequestWithBodyOptions, +} from '../../core/types/internal/http-methods.ts'; import type { EmptyHttpResponse, PaginatedHttpResponse, } from '../../core/types/endpoint/response.ts'; -import type { GetGroupsParams, GetGroupsResponse, Group } from './types.ts'; +import type { GetGroupParams, GetGroupsParams, GetGroupsResponse, Group } from './types.ts'; /** * Provides access to the groups endpoints of the xMatters API. @@ -21,33 +25,31 @@ export class GroupsEndpoint { /** * Get a list of groups from xMatters. - * The results can be filtered and paginated using the params object. + * The results can be filtered and paginated using the options object. * - * @param params Optional parameters to filter and paginate the results - * @param overrides Optional request overrides like custom headers + * @param options Optional parameters including query filters, headers, and other request options * @returns The HTTP response containing a paginated list of groups * @throws {XmApiError} If the request fails */ get( - params?: GetGroupsParams, - overrides?: Pick, + options?: Omit & { path?: string; query?: GetGroupsParams }, ): Promise> { - return this.http.get({ ...overrides, query: params }); + return this.http.get(options); } /** - * Get a group by ID + * Get a group by its ID or targetName. * - * @param id The ID of the group to retrieve - * @param overrides Optional request overrides like custom headers + * @param identifier The ID or targetName of the group to retrieve + * @param options Optional request options including embed parameters and headers * @returns The HTTP response containing the group * @throws {XmApiError} If the request fails */ - getById( - id: string, - overrides?: Pick, + getByIdentifier( + identifier: string, + options?: Omit & { query?: GetGroupParams }, ): Promise> { - return this.http.get({ ...overrides, path: id }); + return this.http.get({ ...options, path: identifier }); } /** @@ -60,7 +62,7 @@ export class GroupsEndpoint { */ save( group: Partial, - overrides?: Pick, + overrides?: Omit, ): Promise> { return this.http.post({ ...overrides, body: group }); } @@ -75,7 +77,7 @@ export class GroupsEndpoint { */ delete( id: string, - overrides?: Pick, + overrides?: Omit, ): Promise { return this.http.delete({ ...overrides, path: id }); } diff --git a/src/endpoints/groups/types.ts b/src/endpoints/groups/types.ts index 0c78256..f48465e 100644 --- a/src/endpoints/groups/types.ts +++ b/src/endpoints/groups/types.ts @@ -1,5 +1,5 @@ import type { PaginatedResponse } from '../../core/types/endpoint/response.ts'; -import type { WithPagination, WithSearch } from '../../core/types/endpoint/composers.ts'; +import type { WithPagination, WithSearch, WithSort } from '../../core/types/endpoint/composers.ts'; /** * Represents a group in xMatters. @@ -59,11 +59,31 @@ export interface GroupFilters extends Record { targetName?: string; } +/** + * Supported embed values for retrieving a single group. + */ +export type GroupEmbedOptions = + | 'supervisors' // Up to the first 100 group supervisors + | 'observers' // Returns the id and name of the role(s) set as observers for the group + | 'services'; // Returns the list of services owned by the group + +/** + * Type for parameters used when retrieving a single group by identifier. + * Supports embedding related objects in the response. + */ +export interface GetGroupParams extends Record { + /** + * Objects to embed in the response. Can be a single value or an array of values. + * For new/undocumented embed options, use type assertion: 'newOption' as GroupEmbedOptions or any + */ + embed?: GroupEmbedOptions | GroupEmbedOptions[]; +} + /** * Type for parameters used in methods that retrieve lists of groups. - * Combines common pagination and search with group-specific filters. + * Combines common pagination and search with group-specific filters and embed options. */ -export type GetGroupsParams = WithPagination>; +export type GetGroupsParams = WithPagination>> & GetGroupParams; /** * Response type for methods that return a list of groups. diff --git a/src/index.test.ts b/src/index.test.ts index acfd6e2..ca57576 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -35,7 +35,7 @@ Deno.test('XmApi - Basic Auth Integration', async () => { }, }]); - const response = await api.groups.get({ limit: 10 }); + const response = await api.groups.get({ query: { limit: 10 } }); expect(response.status).toBe(200); expect(response.body.count).toBe(0); @@ -515,7 +515,7 @@ Deno.test('XmApi - Error Response Structure', async () => { }]); try { - await api.groups.getById('nonexistent'); + await api.groups.getByIdentifier('nonexistent'); throw new Error('Should have thrown XmApiError'); } catch (error) { expect(error).toBeInstanceOf(XmApiError); From 6d506c51e08b57c95d94e6b480b40a9b876ed9aa Mon Sep 17 00:00:00 2001 From: johan Date: Fri, 20 Jun 2025 17:32:26 -0700 Subject: [PATCH 065/101] Simplify and expand types --- deno.json | 3 +- sandbox/validate-docs.ts | 473 +++++++++++++++++++++++++++ src/core/types/endpoint/composers.ts | 97 ------ src/core/types/endpoint/params.ts | 33 +- src/endpoints/groups/types.ts | 98 +++++- 5 files changed, 589 insertions(+), 115 deletions(-) create mode 100644 sandbox/validate-docs.ts delete mode 100644 src/core/types/endpoint/composers.ts diff --git a/deno.json b/deno.json index 665466e..b5fa08c 100644 --- a/deno.json +++ b/deno.json @@ -8,7 +8,8 @@ "tasks": { "test": "DENO_TLS_CA_STORE=system deno test", "cache": "DENO_TLS_CA_STORE=system deno cache --reload src/**/*.ts", - "sandbox": "DENO_TLS_CA_STORE=system deno run --allow-read --allow-net --env-file=sandbox/.env --allow-env sandbox/index.ts" + "sandbox": "DENO_TLS_CA_STORE=system deno run --allow-read --allow-net --env-file=sandbox/.env --allow-env sandbox/index.ts", + "sandbox:validate-docs": "DENO_TLS_CA_STORE=system deno run --allow-read --allow-net --env-file=sandbox/.env --allow-env sandbox/validate-docs.ts" }, "fmt": { "singleQuote": true, diff --git a/sandbox/validate-docs.ts b/sandbox/validate-docs.ts new file mode 100644 index 0000000..dd00358 --- /dev/null +++ b/sandbox/validate-docs.ts @@ -0,0 +1,473 @@ +/** + * Documentation Validation Sandbox + * + * This file is dedicated to validating that the official xMatters API documentation + * accurately represents the actual API behavior. Each test corresponds to specific + * examples or behaviors documented in the official API docs. + * + * Run with: deno task sandbox:validate-docs + */ + +import { XmApi } from '../src/index.ts'; +import type { Group } from '../src/endpoints/groups/types.ts'; +import config from './config.ts'; + +/** + * Assertion helper functions for validating API responses + */ +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } +} + +function assertGroupsMatchStatus(groups: Group[], expectedStatus: 'ACTIVE' | 'INACTIVE'): void { + for (const group of groups) { + assert( + group.status === expectedStatus, + `Group "${group.targetName}" has status "${group.status}", expected "${expectedStatus}"`, + ); + } +} + +function assertGroupsMatchType( + groups: Group[], + expectedType: 'ON_CALL' | 'BROADCAST' | 'DYNAMIC', +): void { + for (const group of groups) { + assert( + group.groupType === expectedType, + `Group "${group.targetName}" has type "${group.groupType}", expected "${expectedType}"`, + ); + } +} + +function assertGroupsAreSorted( + groups: Group[], + sortBy: 'NAME' | 'GROUPTYPE' | 'STATUS', + sortOrder: 'ASCENDING' | 'DESCENDING', +): void { + if (groups.length <= 1) return; // Can't check sorting with 0 or 1 items + + for (let i = 0; i < groups.length - 1; i++) { + const current = groups[i]; + const next = groups[i + 1]; + + let currentValue: string; + let nextValue: string; + + switch (sortBy) { + case 'NAME': + currentValue = current.targetName.toLowerCase(); + nextValue = next.targetName.toLowerCase(); + break; + case 'GROUPTYPE': + currentValue = current.groupType; + nextValue = next.groupType; + break; + case 'STATUS': + currentValue = current.status; + nextValue = next.status; + break; + } + + if (sortOrder === 'ASCENDING') { + assert( + currentValue <= nextValue, + `Groups not sorted by ${sortBy} ascending: "${currentValue}" should come before or equal to "${nextValue}"`, + ); + } else { + assert( + currentValue >= nextValue, + `Groups not sorted by ${sortBy} descending: "${currentValue}" should come after or equal to "${nextValue}"`, + ); + } + } +} + +function assertGroupsMatchSearch( + groups: Group[], + searchTerm: string, + fields?: 'NAME' | 'DESCRIPTION' | 'SERVICE_NAME', +): void { + const searchTermLower = searchTerm.toLowerCase(); + + for (const group of groups) { + let matches = false; + + // Check based on fields parameter + if (!fields || fields === 'NAME' || fields.includes('NAME')) { + if (group.targetName && group.targetName.toLowerCase().includes(searchTermLower)) { + matches = true; + } + } + + if (!fields || fields === 'DESCRIPTION' || fields.includes('DESCRIPTION')) { + if (group.description && group.description.toLowerCase().includes(searchTermLower)) { + matches = true; + } + } + + if (!fields || fields === 'SERVICE_NAME' || fields.includes('SERVICE_NAME')) { + // Note: This would require embedded services data to validate properly + // For now, we'll skip this check unless services are embedded + } + + assert( + matches, + `Group "${group.targetName}" does not match search term "${searchTerm}" in specified fields`, + ); + } +} + +function assertGroupsHaveEmbeddedData(groups: Group[], embedType: string): void { + // Note: The exact structure of embedded data depends on the API response + // This is a basic check that embedded data exists + for (const group of groups) { + if (embedType === 'supervisors' && groups.length > 0) { + // Check if _embedded.supervisors exists or supervisors field is populated + const hasEmbeddedSupervisors = + (group as unknown as { _embedded?: { supervisors?: unknown[] } })._embedded?.supervisors || + (group.supervisors && Array.isArray(group.supervisors)); + // Note: Some groups might not have supervisors, so we just log this + console.log( + ` Group "${group.targetName}": ${ + hasEmbeddedSupervisors ? 'has' : 'no' + } embedded supervisors`, + ); + } + } +} + +/** + * Validates the Groups API query parameters against the official documentation. + * Tests each documented parameter to ensure it works as expected. + */ +async function validateGroupsQueryParameters() { + console.log('\n=== Groups API Query Parameters Validation ==='); + + const { hostname, username, password } = config.basicAuth; + if (!hostname || !username || !password) { + console.warn('[SKIP] Groups validation: Missing basic auth credentials'); + return; + } + + try { + const xm = new XmApi(config.basicAuth); + + // Test 1: Basic groups retrieval + console.log('\n[TEST 1] Basic groups retrieval (limit=3)'); + const basicGroups = await xm.groups.get({ query: { limit: 3 } }); + console.log(`✓ Success: ${basicGroups.body.data.length} groups returned`); + console.log(` Status: ${basicGroups.status}`); + console.log(` Total: ${basicGroups.body.total}`); + + // Test 2: Status filtering (documented: ACTIVE, INACTIVE) + console.log('\n[TEST 2] Status filtering: status=ACTIVE'); + const activeGroups = await xm.groups.get({ + query: { status: 'ACTIVE', limit: 5 }, + }); + console.log(`✓ API Response: ${activeGroups.body.data.length} groups returned`); + assertGroupsMatchStatus(activeGroups.body.data, 'ACTIVE'); + console.log(`✓ Assertion: All ${activeGroups.body.data.length} groups have status=ACTIVE`); + + console.log('\n[TEST 3] Status filtering: status=INACTIVE'); + const inactiveGroups = await xm.groups.get({ + query: { status: 'INACTIVE', limit: 5 }, + }); + console.log(`✓ API Response: ${inactiveGroups.body.data.length} groups returned`); + if (inactiveGroups.body.data.length > 0) { + assertGroupsMatchStatus(inactiveGroups.body.data, 'INACTIVE'); + console.log( + `✓ Assertion: All ${inactiveGroups.body.data.length} groups have status=INACTIVE`, + ); + } else { + console.log(` Note: No inactive groups found in the system`); + } + + // Test 4: Group type filtering (documented: ON_CALL, BROADCAST, DYNAMIC) + console.log('\n[TEST 4] Group type filtering: groupType=BROADCAST'); + const broadcastGroups = await xm.groups.get({ + query: { groupType: 'BROADCAST', limit: 3 }, + }); + console.log(`✓ API Response: ${broadcastGroups.body.data.length} groups returned`); + if (broadcastGroups.body.data.length > 0) { + assertGroupsMatchType(broadcastGroups.body.data, 'BROADCAST'); + console.log( + `✓ Assertion: All ${broadcastGroups.body.data.length} groups have groupType=BROADCAST`, + ); + } else { + console.log(` Note: No broadcast groups found in the system`); + } + + console.log('\n[TEST 5] Group type filtering: groupType=ON_CALL'); + const onCallGroups = await xm.groups.get({ + query: { groupType: 'ON_CALL', limit: 3 }, + }); + console.log(`✓ API Response: ${onCallGroups.body.data.length} groups returned`); + if (onCallGroups.body.data.length > 0) { + assertGroupsMatchType(onCallGroups.body.data, 'ON_CALL'); + console.log( + `✓ Assertion: All ${onCallGroups.body.data.length} groups have groupType=ON_CALL`, + ); + } else { + console.log(` Note: No on-call groups found in the system`); + } + + // Test 6: Sorting (documented: NAME, GROUPTYPE, STATUS with ASCENDING, DESCENDING) + console.log('\n[TEST 6] Sorting: sortBy=NAME, sortOrder=ASCENDING'); + const sortedByName = await xm.groups.get({ + query: { + sortBy: 'NAME', + sortOrder: 'ASCENDING', + limit: 3, + }, + }); + console.log(`✓ API Response: ${sortedByName.body.data.length} groups returned`); + if (sortedByName.body.data.length > 1) { + assertGroupsAreSorted(sortedByName.body.data, 'NAME', 'ASCENDING'); + console.log(`✓ Assertion: Groups are sorted by name in ascending order`); + console.log(` First group: "${sortedByName.body.data[0]?.targetName}"`); + console.log( + ` Last group: "${sortedByName.body.data[sortedByName.body.data.length - 1]?.targetName}"`, + ); + } else { + console.log(` Note: Cannot verify sorting with ${sortedByName.body.data.length} groups`); + } + + console.log('\n[TEST 7] Sorting: sortBy=GROUPTYPE, sortOrder=ASCENDING'); + const sortedByType = await xm.groups.get({ + query: { + sortBy: 'GROUPTYPE', + sortOrder: 'ASCENDING', + limit: 5, + }, + }); + console.log(`✓ API Response: ${sortedByType.body.data.length} groups returned`); + if (sortedByType.body.data.length > 1) { + assertGroupsAreSorted(sortedByType.body.data, 'GROUPTYPE', 'ASCENDING'); + console.log(`✓ Assertion: Groups are sorted by group type in ascending order`); + const groupTypes = sortedByType.body.data.map((g) => g.groupType).join(', '); + console.log(` Group types: ${groupTypes}`); + } else { + const groupTypes = sortedByType.body.data.map((g) => g.groupType).join(', '); + console.log(` Group types: ${groupTypes}`); + console.log(` Note: Cannot verify sorting with ${sortedByType.body.data.length} groups`); + } + + // Test 8: Search functionality + console.log('\n[TEST 8] Search: search="admin"'); + const searchResults = await xm.groups.get({ + query: { search: 'admin', limit: 5 }, + }); + console.log(`✓ API Response: ${searchResults.body.data.length} groups returned`); + if (searchResults.body.data.length > 0) { + try { + assertGroupsMatchSearch(searchResults.body.data, 'admin'); + console.log( + `✓ Assertion: All ${searchResults.body.data.length} groups contain "admin" in name or description`, + ); + } catch (err) { + console.log(`⚠️ Search assertion: ${err instanceof Error ? err.message : 'Unknown error'}`); + console.log( + ` Note: Some groups may match in non-visible fields or have complex search logic`, + ); + } + } else { + console.log(` Note: No groups found matching "admin"`); + } + + // Test 9: Search operand (documented: AND, OR) + console.log('\n[TEST 9] Search operand: search="admin database", operand=OR'); + const searchOr = await xm.groups.get({ + query: { + search: 'admin database', + operand: 'OR', + limit: 5, + }, + }); + console.log(`✓ API Response: ${searchOr.body.data.length} groups returned`); + if (searchOr.body.data.length > 0) { + // For OR operand, groups should match either "admin" OR "database" + let matchingGroups = 0; + for (const group of searchOr.body.data) { + const nameMatch = group.targetName.toLowerCase().includes('admin') || + group.targetName.toLowerCase().includes('database'); + const descMatch = group.description?.toLowerCase().includes('admin') || + group.description?.toLowerCase().includes('database'); + if (nameMatch || descMatch) { + matchingGroups++; + } + } + console.log( + `✓ Assertion: ${matchingGroups}/${searchOr.body.data.length} groups contain "admin" OR "database"`, + ); + if (matchingGroups < searchOr.body.data.length) { + console.log(` Note: Some groups may match in non-visible fields`); + } + } else { + console.log(` Note: No groups found matching "admin" OR "database"`); + } + + // Test 10: Fields filtering (documented: NAME, DESCRIPTION, SERVICE_NAME) + console.log('\n[TEST 10] Fields filtering: search="admin", fields=NAME'); + const nameSearch = await xm.groups.get({ + query: { + search: 'admin', + fields: 'NAME', + limit: 3, + }, + }); + console.log(`✓ API Response: ${nameSearch.body.data.length} groups returned`); + if (nameSearch.body.data.length > 0) { + try { + assertGroupsMatchSearch(nameSearch.body.data, 'admin', 'NAME'); + console.log( + `✓ Assertion: All ${nameSearch.body.data.length} groups contain "admin" in name`, + ); + const groupNames = nameSearch.body.data.map((g) => g.targetName).join(', '); + console.log(` Group names: ${groupNames}`); + } catch (err) { + console.log(`⚠️ Search assertion: ${err instanceof Error ? err.message : 'Unknown error'}`); + const groupNames = nameSearch.body.data.map((g) => g.targetName).join(', '); + console.log(` Group names: ${groupNames}`); + } + } else { + console.log(` Note: No groups found with "admin" in name`); + } + + // Test 11: Embed options (documented: supervisors, observers, services, criteria) + console.log('\n[TEST 11] Embed: embed=supervisors'); + const withSupervisors = await xm.groups.get({ + query: { + embed: ['supervisors'], + limit: 2, + }, + }); + console.log(`✓ API Response: ${withSupervisors.body.data.length} groups returned`); + if (withSupervisors.body.data.length > 0) { + assertGroupsHaveEmbeddedData(withSupervisors.body.data, 'supervisors'); + console.log(`✓ Assertion: Checked for embedded supervisors data`); + } + + console.log('\n[TEST 12] Embed: embed=observers'); + const withObservers = await xm.groups.get({ + query: { + embed: ['observers'], + limit: 2, + }, + }); + console.log(`✓ API Response: ${withObservers.body.data.length} groups returned`); + if (withObservers.body.data.length > 0) { + assertGroupsHaveEmbeddedData(withObservers.body.data, 'observers'); + console.log(`✓ Assertion: Checked for embedded observers data`); + } + + // Test 13: Single group retrieval with embed + if (basicGroups.body.data.length > 0) { + const firstGroup = basicGroups.body.data[0]; + console.log( + `\n[TEST 13] Single group: getByIdentifier("${firstGroup.id}") with embed=services`, + ); + const singleGroup = await xm.groups.getByIdentifier(firstGroup.id, { + query: { embed: ['services'] }, + }); + console.log(`✓ Success: Retrieved group "${singleGroup.body.targetName}"`); + console.log(` Group type: ${singleGroup.body.groupType}`); + console.log(` Status: ${singleGroup.body.status}`); + } + + console.log('\n🎉 All Groups API documentation validation tests passed!'); + } catch (err) { + console.error('\n❌ Documentation validation failed:', err); + if (err instanceof Error) { + console.error('Error message:', err.message); + } + } +} + +/** + * Validates edge cases and error scenarios mentioned in documentation + */ +async function validateEdgeCases() { + console.log('\n=== Edge Cases & Error Scenarios Validation ==='); + + const { hostname, username, password } = config.basicAuth; + if (!hostname || !username || !password) { + console.warn('[SKIP] Edge cases validation: Missing basic auth credentials'); + return; + } + + try { + const xm = new XmApi(config.basicAuth); + + // Test 1: Invalid group ID (should return 404) + console.log('\n[TEST 1] Invalid group ID retrieval'); + try { + await xm.groups.getByIdentifier('non-existent-group-id'); + console.log('❌ Expected 404 error but request succeeded'); + } catch (err: unknown) { + const error = err as { response?: { status?: number } }; + if (error.response?.status === 404) { + console.log('✓ Success: 404 error as expected for invalid group ID'); + } else { + console.log(`❓ Unexpected error status: ${error.response?.status || 'unknown'}`); + } + } + + // Test 2: Invalid sortBy value (should return error) + console.log('\n[TEST 2] Invalid sortBy value'); + try { + await xm.groups.get({ + query: { + // @ts-expect-error - Testing invalid value + sortBy: 'INVALID_SORT_FIELD', + limit: 1, + }, + }); + console.log('❌ Expected error for invalid sortBy but request succeeded'); + } catch (err: unknown) { + const error = err as { response?: { status?: number } }; + console.log( + `✓ Success: Error as expected for invalid sortBy (${error.response?.status || 'unknown'})`, + ); + } + + // Test 3: Invalid operand value (should return error) + console.log('\n[TEST 3] Invalid operand value'); + try { + await xm.groups.get({ + query: { + search: 'test', + // @ts-expect-error - Testing invalid value + operand: 'INVALID_OPERAND', + limit: 1, + }, + }); + console.log('❌ Expected error for invalid operand but request succeeded'); + } catch (err: unknown) { + const error = err as { response?: { status?: number } }; + console.log( + `✓ Success: Error as expected for invalid operand (${error.response?.status || 'unknown'})`, + ); + } + } catch (err) { + console.error('\n❌ Edge cases validation failed:', err); + } +} + +/** + * Main validation runner + */ +async function main() { + console.log('🔍 Starting xMatters API Documentation Validation'); + console.log('================================================'); + + await validateGroupsQueryParameters(); + await validateEdgeCases(); + + console.log('\n✅ Documentation validation complete!'); +} + +// Run the validation +await main(); diff --git a/src/core/types/endpoint/composers.ts b/src/core/types/endpoint/composers.ts deleted file mode 100644 index 82a5863..0000000 --- a/src/core/types/endpoint/composers.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Type composers for composing endpoint parameter types. - * These utilities make it easy to build complex parameter types by composing simpler ones. - */ - -import type { PaginationParams, SearchParams, SortParams } from './params.ts'; - -/** - * Helper type to add pagination to endpoint parameters - */ -export type WithPagination = Record> = - & T - & PaginationParams; - -/** - * Helper type to add search capability to endpoint parameters - */ -export type WithSearch = Record> = - & T - & SearchParams; - -/** - * Helper type to add sorting to endpoint parameters - */ -export type WithSort = Record> = T & SortParams; - -/** - * Common type utilities for composing endpoint parameter types. - * - * @example Simple paginated endpoint - * ```typescript - * interface DeviceFilters extends Record { - * status?: 'ACTIVE' | 'INACTIVE'; - * } - * - * type GetDevicesParams = WithPagination; - * // Results in: - * // { - * // status?: 'ACTIVE' | 'INACTIVE'; - * // limit?: number; - * // offset?: number; - * // } - * ``` - * - * @example Endpoint with search and pagination - * ```typescript - * interface UserFilters extends Record { - * role?: string; - * } - * - * // Compose multiple parameter types - * type GetUsersParams = WithPagination>; - * // Results in: - * // { - * // role?: string; - * // search?: string; - * // limit?: number; - * // offset?: number; - * // } - * ``` - * - * @example Full endpoint type definition - * ```typescript - * // 1. Define your resource type - * interface User { - * id: string; - * name: string; - * // ...other properties - * } - * - * // 2. Define endpoint-specific filters - * interface UserFilters extends Record { - * role?: string; - * status?: 'ACTIVE' | 'INACTIVE'; - * } - * - * // 3. Compose parameter types with pagination, search, and sort - * type GetUsersParams = WithPagination>>; - * - * // 4. Use the HTTP response wrapper types for return types - * // For paginated responses: - * type GetUsersResponse = PaginatedHttpResponse; - * // For single resource responses: - * type GetUserResponse = ResourceHttpResponse; - * - * // Now you can implement your endpoint: - * class UsersEndpoint { - * async getUsers(params?: GetUsersParams): GetUsersResponse { - * return this.http.get({ path: '/users', query: params }); - * } - * - * async getByIdentifier(id: string): GetUserResponse { - * return this.http.get({ path: `/${id}` }); - * } - * } - * ``` - */ diff --git a/src/core/types/endpoint/params.ts b/src/core/types/endpoint/params.ts index 8fab42c..df44d26 100644 --- a/src/core/types/endpoint/params.ts +++ b/src/core/types/endpoint/params.ts @@ -3,6 +3,12 @@ * These provide standardized parameter shapes that endpoints can use and compose. */ +/** + * Base type for query parameter objects. + * Represents any object with string keys and unknown values. + */ +export type QueryParams = Record; + /** * Common pagination parameters used across many endpoints */ @@ -30,20 +36,27 @@ export interface SearchParams { * The search is typically case-insensitive and matches any part of the searchable fields */ search?: string; -} -/** - * Common sorting parameters used across many endpoints - */ -export interface SortParams { /** - * Field to sort by + * The operand to use to limit or expand the search query parameter. + * - AND: only returns records that have all search terms + * - OR: returns records that have any of the search terms (default) + * The operand is case-sensitive. */ - sortBy?: string; + operand?: 'AND' | 'OR'; +} +/** + * Common status filtering parameters used across many endpoints + */ +export interface StatusParams { /** - * Sort direction - * @default 'ASC' + * The status of the resource. */ - sortOrder?: 'ASC' | 'DESC'; + status?: 'ACTIVE' | 'INACTIVE'; } + +/** + * Sort direction values used across all endpoints + */ +export type SortOrder = 'ASCENDING' | 'DESCENDING'; diff --git a/src/endpoints/groups/types.ts b/src/endpoints/groups/types.ts index f48465e..a6ceddf 100644 --- a/src/endpoints/groups/types.ts +++ b/src/endpoints/groups/types.ts @@ -1,5 +1,11 @@ import type { PaginatedResponse } from '../../core/types/endpoint/response.ts'; -import type { WithPagination, WithSearch, WithSort } from '../../core/types/endpoint/composers.ts'; +import type { + PaginationParams, + QueryParams, + SearchParams, + SortOrder, + StatusParams, +} from '../../core/types/endpoint/params.ts'; /** * Represents a group in xMatters. @@ -48,24 +54,96 @@ export interface Group { lastModified?: string; } +/** + * Individual search field options that can be combined + */ +export type GroupSearchField = 'NAME' | 'DESCRIPTION' | 'SERVICE_NAME'; + /** * Type for filters that can be applied when retrieving groups. */ -export interface GroupFilters extends Record { +export interface GroupFilters extends QueryParams { /** * Filter records by matching on the exact value of targetName. * This is case-sensitive and must match the group name exactly. */ targetName?: string; + + /** + * Defines the field to search when a search term is specified. + * Can specify individual fields or arrays of fields to search. + * - NAME: searches only the group name + * - DESCRIPTION: searches only the group description + * - SERVICE_NAME: searches for the name of a service + */ + fields?: GroupSearchField | GroupSearchField[]; + + /** + * Specifies the group type to return in the response. + */ + groupType?: 'ON_CALL' | 'BROADCAST' | 'DYNAMIC'; + + /** + * The targetName or id of the users, or devices that are members of an on-call or broadcast group. + * Can be a comma-separated list for multiple members. + * Returns all groups that contain any of the queried members. + */ + members?: string | string[]; + + /** + * Returns a list of groups that have shifts created, but no members added to the shifts. + * - ALL_SHIFTS: Returns groups that have no members added to any shifts + * - ANY_SHIFTS: Returns groups that have at least one shift with no members added to it + */ + 'member.exists'?: 'ALL_SHIFTS' | 'ANY_SHIFTS'; + + /** + * Returns a list of groups that contain at least one member (or a device that belongs to a user) + * who has the specified license type. The member does not have to be part of any shifts for the + * group to be included in the response. + */ + 'member.licenseType'?: 'FULL_USER' | 'STAKEHOLDER_USER'; + + /** + * A comma-separated list of sites whose groups you want to retrieve. + * You can specify the site using its unique identifier (id) or name (case-insensitive), or both. + * When two or more sites are sent in the request, the response includes groups for which either site is assigned. + */ + sites?: string | string[]; + + /** + * A comma-separated list of supervisors whose groups you want to retrieve. + * You can specify the supervisors using targetName (case-insensitive) or id (or both if searching for multiple supervisors). + * When two or more supervisors are sent in the request, the response includes groups for which either user is a supervisor. + */ + supervisors?: string | string[]; +} + +/** + * Group-specific sort parameters + */ +export interface GroupSortParams { + /** + * Field to sort by + */ + sortBy?: 'NAME' | 'GROUPTYPE' | 'STATUS'; + + /** + * Sort direction + * @default 'ASCENDING' + */ + sortOrder?: SortOrder; } /** - * Supported embed values for retrieving a single group. + * Supported embed values for retrieving groups. + * These apply to both single group and multiple groups endpoints. */ export type GroupEmbedOptions = - | 'supervisors' // Up to the first 100 group supervisors + | 'supervisors' // Up to the first 100 group supervisors (single group) or paginated list (multiple groups) | 'observers' // Returns the id and name of the role(s) set as observers for the group - | 'services'; // Returns the list of services owned by the group + | 'services' // Returns the list of services owned by the group + | 'criteria'; // Returns the criteria specified for dynamic groups (only applicable when groupType=DYNAMIC) /** * Type for parameters used when retrieving a single group by identifier. @@ -81,9 +159,15 @@ export interface GetGroupParams extends Record { /** * Type for parameters used in methods that retrieve lists of groups. - * Combines common pagination and search with group-specific filters and embed options. + * Combines common pagination, search, status, sort, and group-specific filters and embed options. */ -export type GetGroupsParams = WithPagination>> & GetGroupParams; +export type GetGroupsParams = + & PaginationParams + & SearchParams + & StatusParams + & GroupFilters + & GroupSortParams + & GetGroupParams; /** * Response type for methods that return a list of groups. From 0db6d166f3ae52d1464a35729ef718b7b866f19a Mon Sep 17 00:00:00 2001 From: johan Date: Sun, 22 Jun 2025 22:54:14 -0700 Subject: [PATCH 066/101] Refine testing patterns --- src/core/test-utils.ts | 16 ++-- src/index.test.ts | 187 ++++++++++++++++++----------------------- 2 files changed, 90 insertions(+), 113 deletions(-) diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts index 37a6b6f..b308d69 100644 --- a/src/core/test-utils.ts +++ b/src/core/test-utils.ts @@ -41,10 +41,12 @@ export class MockHttpClient implements HttpClient { send(request: HttpRequest): Promise { this.requests.push(request); if (this.requestIndex >= this.requestResponsePairs.length) { - throw new Error( - `MockHttpClient: Unexpected request #${ - this.requestIndex + 1 - }. Expected ${this.requestResponsePairs.length} requests total.`, + return Promise.reject( + new Error( + `MockHttpClient: Unexpected request #${ + this.requestIndex + 1 + }. Expected ${this.requestResponsePairs.length} requests total.`, + ), ); } const currentPair = this.requestResponsePairs[this.requestIndex]; @@ -54,8 +56,10 @@ export class MockHttpClient implements HttpClient { return Promise.reject(currentPair.mockedError); } if (!currentPair.mockedResponse) { - throw new Error( - `MockHttpClient: Request #${this.requestIndex} must have either mockedResponse or mockedError`, + return Promise.reject( + new Error( + `MockHttpClient: Request #${this.requestIndex} must have either mockedResponse or mockedError`, + ), ); } return Promise.resolve(currentPair.mockedResponse); diff --git a/src/index.test.ts b/src/index.test.ts index ca57576..9f3f3c3 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -7,7 +7,6 @@ const mockHttpClient = new MockHttpClient(); Deno.test('XmApi - Basic Auth Integration', async () => { const { mockLogger } = createMockLogger(); - const api = new XmApi({ hostname: 'test.xmatters.com', username: 'testuser', @@ -15,7 +14,6 @@ Deno.test('XmApi - Basic Auth Integration', async () => { httpClient: mockHttpClient, logger: mockLogger, }); - // Test a simple GET request mockHttpClient.setReqRes([{ expectedRequest: { @@ -34,17 +32,14 @@ Deno.test('XmApi - Basic Auth Integration', async () => { body: { count: 0, total: 0, data: [] }, }, }]); - const response = await api.groups.get({ query: { limit: 10 } }); expect(response.status).toBe(200); expect(response.body.count).toBe(0); - mockHttpClient.verifyAllRequestsMade(); }); Deno.test('XmApi - OAuth Token Integration', async () => { const { mockLogger } = createMockLogger(); - const api = new XmApi({ hostname: 'test.xmatters.com', accessToken: 'test-access-token', @@ -53,7 +48,6 @@ Deno.test('XmApi - OAuth Token Integration', async () => { httpClient: mockHttpClient, logger: mockLogger, }); - // Test OAuth Bearer token is used mockHttpClient.setReqRes([{ expectedRequest: { @@ -72,11 +66,9 @@ Deno.test('XmApi - OAuth Token Integration', async () => { body: { count: 1, total: 1, data: [{ id: '123', targetName: 'Test Group' }] }, }, }]); - const response = await api.groups.get(); expect(response.status).toBe(200); expect(response.body.data).toHaveLength(1); - mockHttpClient.verifyAllRequestsMade(); }); @@ -85,7 +77,6 @@ Deno.test('XmApi - Token Refresh on 401', async () => { let tokenRefreshCalled = false; let newAccessToken = ''; let newRefreshToken = ''; - const api = new XmApi({ hostname: 'test.xmatters.com', accessToken: 'expired-token', @@ -99,7 +90,6 @@ Deno.test('XmApi - Token Refresh on 401', async () => { newRefreshToken = refreshToken; }, }); - mockHttpClient.setReqRes([ // First request fails with 401 { @@ -161,19 +151,16 @@ Deno.test('XmApi - Token Refresh on 401', async () => { }, }, ]); - const response = await api.groups.get(); expect(response.status).toBe(200); expect(tokenRefreshCalled).toBe(true); expect(newAccessToken).toBe('new-access-token'); expect(newRefreshToken).toBe('new-refresh-token'); - mockHttpClient.verifyAllRequestsMade(); }); Deno.test('XmApi - Token Refresh Callback Error Handling', async () => { const { mockLogger, warnSpy } = createMockLogger(); - const api = new XmApi({ hostname: 'test.xmatters.com', accessToken: 'expired-token', @@ -185,7 +172,6 @@ Deno.test('XmApi - Token Refresh Callback Error Handling', async () => { throw new Error('Callback error'); }, }); - mockHttpClient.setReqRes([ // First request fails with 401 { @@ -247,22 +233,18 @@ Deno.test('XmApi - Token Refresh Callback Error Handling', async () => { }, }, ]); - // Should not throw despite callback error const response = await api.groups.get(); expect(response.status).toBe(200); - // Should log warning about callback error expect(warnSpy.calls).toHaveLength(1); expect(warnSpy.calls[0].args[0]).toContain('Error in onTokenRefresh callback'); - mockHttpClient.verifyAllRequestsMade(); }); Deno.test('XmApi - Retry Logic for 429 Rate Limit', async () => { return await withFakeTime(async (fakeTime) => { const { mockLogger, debugSpy } = createMockLogger(); - const api = new XmApi({ hostname: 'test.xmatters.com', username: 'testuser', @@ -271,9 +253,8 @@ Deno.test('XmApi - Retry Logic for 429 Rate Limit', async () => { logger: mockLogger, maxRetries: 2, }); - mockHttpClient.setReqRes([ - // First request fails with 429 + // First request fails with 429 rate limit { expectedRequest: { method: 'GET', @@ -287,11 +268,11 @@ Deno.test('XmApi - Retry Logic for 429 Rate Limit', async () => { }, mockedResponse: { status: 429, - headers: { 'Retry-After': '1' }, // 1 second + headers: { 'Retry-After': '1' }, // Server requests 1 second delay body: { error: 'Rate limit exceeded' }, }, }, - // Second request also fails with 429 + // First retry also fails with 429 rate limit { expectedRequest: { method: 'GET', @@ -309,7 +290,7 @@ Deno.test('XmApi - Retry Logic for 429 Rate Limit', async () => { body: { error: 'Rate limit exceeded' }, }, }, - // Third request succeeds + // Second retry succeeds { expectedRequest: { method: 'GET', @@ -328,23 +309,29 @@ Deno.test('XmApi - Retry Logic for 429 Rate Limit', async () => { }, }, ]); - - // Start the request but don't await it yet + // Start the request without awaiting to allow fake time control const requestPromise = api.groups.get(); - - // Advance time to process the retries - await fakeTime.nextAsync(); // First request + first retry - await fakeTime.nextAsync(); // Second retry + final success - - const response = await requestPromise; - expect(response.status).toBe(200); - - // Should log retry attempts + const [response] = await Promise.allSettled([ + requestPromise, + // Advance fake time to trigger scheduled retry delays + // Pattern: request -> setTimeout for retry delay -> retry -> setTimeout -> retry + fakeTime.nextAsync(), // Executes first setTimeout (1s delay), triggering first retry + fakeTime.nextAsync(), // Executes second setTimeout (1s delay), triggering second retry + ]); + if (response.status === 'fulfilled') { + expect(response.value.status).toBe(200); + } else { + throw new Error( + `TEST SETUP ERROR: Expected request to succeed for retry logic test, but it was rejected. ` + + `This likely indicates a problem with the test setup (mock expectations, fake time, etc.). ` + + `Original error: ${response.reason}`, + ); + } + // Verify that retry attempts were logged (2 failures logged before final success) const debugCalls = debugSpy.calls.filter((call) => call.args[0].includes('Request failed with status 429') ); expect(debugCalls).toHaveLength(2); - mockHttpClient.verifyAllRequestsMade(); }); }); @@ -352,7 +339,6 @@ Deno.test('XmApi - Retry Logic for 429 Rate Limit', async () => { Deno.test('XmApi - Retry Logic for 500 Server Error', async () => { return await withFakeTime(async (fakeTime) => { const { mockLogger } = createMockLogger(); - const api = new XmApi({ hostname: 'test.xmatters.com', username: 'testuser', @@ -361,9 +347,8 @@ Deno.test('XmApi - Retry Logic for 500 Server Error', async () => { logger: mockLogger, maxRetries: 1, }); - mockHttpClient.setReqRes([ - // First request fails with 500 + // First request fails with 500 server error { expectedRequest: { method: 'GET', @@ -381,7 +366,7 @@ Deno.test('XmApi - Retry Logic for 500 Server Error', async () => { body: { error: 'Internal Server Error' }, }, }, - // Second request succeeds + // Retry succeeds after exponential backoff delay { expectedRequest: { method: 'GET', @@ -400,16 +385,23 @@ Deno.test('XmApi - Retry Logic for 500 Server Error', async () => { }, }, ]); - - // Start the request but don't await it yet + // Start the request without awaiting to allow fake time control const requestPromise = api.groups.get(); - - // Advance time to process the retry - await fakeTime.nextAsync(); // First request + retry with exponential backoff - - const response = await requestPromise; - expect(response.status).toBe(200); - + const [response] = await Promise.allSettled([ + requestPromise, + // Advance fake time to trigger scheduled retry delay + // Pattern: request -> setTimeout for exponential backoff -> retry + fakeTime.nextAsync(), // Executes setTimeout (1s exponential backoff), triggering retry + ]); + if (response.status === 'fulfilled') { + expect(response.value.status).toBe(200); + } else { + throw new Error( + `TEST SETUP ERROR: Expected request to succeed for 500 server error retry test, but it was rejected. ` + + `This likely indicates a problem with the test setup (mock expectations, fake time, etc.). ` + + `Original error: ${response.reason}`, + ); + } mockHttpClient.verifyAllRequestsMade(); }); }); @@ -417,7 +409,6 @@ Deno.test('XmApi - Retry Logic for 500 Server Error', async () => { Deno.test('XmApi - Max Retries Exceeded', async () => { return await withFakeTime(async (fakeTime) => { const { mockLogger } = createMockLogger(); - const api = new XmApi({ hostname: 'test.xmatters.com', username: 'testuser', @@ -426,9 +417,8 @@ Deno.test('XmApi - Max Retries Exceeded', async () => { logger: mockLogger, maxRetries: 1, }); - mockHttpClient.setReqRes([ - // First request fails with 500 + // First request fails with 500 server error { expectedRequest: { method: 'GET', @@ -446,7 +436,7 @@ Deno.test('XmApi - Max Retries Exceeded', async () => { body: { reason: 'Internal Server Error' }, }, }, - // Second request also fails with 500 + // Retry also fails with 500, exhausting maxRetries (1) { expectedRequest: { method: 'GET', @@ -465,27 +455,34 @@ Deno.test('XmApi - Max Retries Exceeded', async () => { }, }, ]); - - // Start the request but don't await it yet - const requestPromise = api.groups.get().catch((error) => error); - - // Advance time to process all retry attempts - await fakeTime.nextAsync(); // First request + first retry (both fail) - - const error = await requestPromise; - expect(error).toBeInstanceOf(XmApiError); - const apiError = error as XmApiError; - // Should throw with the extracted error message from the response - expect(apiError.message).toBe('Internal Server Error'); - expect(apiError.response?.status).toBe(500); - + // Start the request without awaiting to allow fake time control + const requestPromise = api.groups.get(); + const [result] = await Promise.allSettled([ + requestPromise, + // Advance fake time to process retry attempts until max retries exceeded + // Pattern: request -> setTimeout for exponential backoff -> retry -> throw error + fakeTime.nextAsync(), // Executes setTimeout (1s exponential backoff), triggering retry that also fails + ]); + if (result.status === 'rejected') { + const error = result.reason; + expect(error).toBeInstanceOf(XmApiError); + const apiError = error as XmApiError; + // Should throw with the extracted error message from the response + expect(apiError.message).toBe('Internal Server Error'); + expect(apiError.response?.status).toBe(500); + } else { + throw new Error( + `TEST SETUP ERROR: Expected request to fail after max retries exceeded, but it succeeded. ` + + `This likely indicates a problem with the test setup (mock expectations, fake time, etc.). ` + + `Actual response: ${JSON.stringify(result.value)}`, + ); + } mockHttpClient.verifyAllRequestsMade(); }); }); -Deno.test('XmApi - Error Response Structure', async () => { +Deno.test('XmApi - HTTP Error Response Structure', async () => { const { mockLogger } = createMockLogger(); - const api = new XmApi({ hostname: 'test.xmatters.com', username: 'testuser', @@ -493,13 +490,6 @@ Deno.test('XmApi - Error Response Structure', async () => { httpClient: mockHttpClient, logger: mockLogger, }); - - const errorResponse = { - status: 404, - headers: { 'Content-Type': 'application/json' }, - body: { error: 'Group not found', code: 'GROUP_NOT_FOUND' }, - }; - mockHttpClient.setReqRes([{ expectedRequest: { method: 'GET', @@ -511,27 +501,31 @@ Deno.test('XmApi - Error Response Structure', async () => { 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, - mockedResponse: errorResponse, + mockedResponse: { + status: 404, + headers: { 'Content-Type': 'application/json' }, + body: { error: 'Group not found', code: 'GROUP_NOT_FOUND' }, + }, }]); - + // Test HTTP error response handling (server responds with 404 status) + // This tests a different scenario than network errors - here the server successfully + // responds but with an error status code, so XmApiError should contain response details try { await api.groups.getByIdentifier('nonexistent'); - throw new Error('Should have thrown XmApiError'); } catch (error) { - expect(error).toBeInstanceOf(XmApiError); const apiError = error as XmApiError; + expect(apiError).toBeInstanceOf(XmApiError); expect(apiError.response).toBeDefined(); expect(apiError.response?.status).toBe(404); expect(apiError.response?.body).toEqual({ error: 'Group not found', code: 'GROUP_NOT_FOUND' }); expect(apiError.response?.headers).toEqual({ 'Content-Type': 'application/json' }); + } finally { + mockHttpClient.verifyAllRequestsMade(); } - - mockHttpClient.verifyAllRequestsMade(); }); Deno.test('XmApi - Network Error Handling', async () => { const { mockLogger } = createMockLogger(); - const api = new XmApi({ hostname: 'test.xmatters.com', username: 'testuser', @@ -539,7 +533,6 @@ Deno.test('XmApi - Network Error Handling', async () => { httpClient: mockHttpClient, logger: mockLogger, }); - // Use mockedError to simulate network connection failure mockHttpClient.setReqRes([{ expectedRequest: { @@ -554,24 +547,22 @@ Deno.test('XmApi - Network Error Handling', async () => { }, mockedError: new Error('Network connection failed'), }]); - + // MockHttpClient with mockedError will always reject, so we can test error handling directly try { await api.groups.get(); - throw new Error('Should have thrown XmApiError'); } catch (error) { - expect(error).toBeInstanceOf(XmApiError); const apiError = error as XmApiError; + expect(apiError).toBeInstanceOf(XmApiError); expect(apiError.message).toBe('Request failed'); expect(apiError.response).toBeNull(); expect((apiError.cause as Error)?.message).toBe('Network connection failed'); + } finally { + mockHttpClient.verifyAllRequestsMade(); } - - mockHttpClient.verifyAllRequestsMade(); }); Deno.test('XmApi - Custom Headers Integration', async () => { const { mockLogger } = createMockLogger(); - const api = new XmApi({ hostname: 'test.xmatters.com', username: 'testuser', @@ -583,7 +574,6 @@ Deno.test('XmApi - Custom Headers Integration', async () => { 'X-Client-Version': '1.0.0', }, }); - mockHttpClient.setReqRes([{ expectedRequest: { method: 'GET', @@ -603,17 +593,14 @@ Deno.test('XmApi - Custom Headers Integration', async () => { body: { count: 0, total: 0, data: [] }, }, }]); - const response = await api.groups.get(); expect(response.status).toBe(200); - mockHttpClient.verifyAllRequestsMade(); }); Deno.test('XmApi - OAuth Token Acquisition', async () => { const { mockLogger } = createMockLogger(); let tokenRefreshCalled = false; - const api = new XmApi({ hostname: 'test.xmatters.com', username: 'testuser', @@ -626,7 +613,6 @@ Deno.test('XmApi - OAuth Token Acquisition', async () => { expect(refreshToken).toBe('obtained-refresh-token'); }, }); - // We'll validate the URL params more flexibly since URLSearchParams order can vary mockHttpClient.setReqRes([{ expectedRequest: { @@ -650,12 +636,10 @@ Deno.test('XmApi - OAuth Token Acquisition', async () => { }, }, }]); - const response = await api.oauth.obtainTokens({ clientId: 'test-client' }); expect(response.status).toBe(200); expect(response.body.access_token).toBe('obtained-access-token'); expect(tokenRefreshCalled).toBe(true); - // Validate that the request body contains the expected parameters const request = mockHttpClient.requests[0]; const bodyString = request.body as string; @@ -663,13 +647,11 @@ Deno.test('XmApi - OAuth Token Acquisition', async () => { expect(bodyString).toContain('username=testuser'); expect(bodyString).toContain('password=testpass'); expect(bodyString).toContain('client_id=test-client'); - mockHttpClient.verifyAllRequestsMade(); }); Deno.test('XmApi - User-Agent Header', async () => { const { mockLogger } = createMockLogger(); - const api = new XmApi({ hostname: 'test.xmatters.com', username: 'testuser', @@ -677,7 +659,6 @@ Deno.test('XmApi - User-Agent Header', async () => { httpClient: mockHttpClient, logger: mockLogger, }); - mockHttpClient.setReqRes([{ expectedRequest: { method: 'GET', @@ -695,16 +676,13 @@ Deno.test('XmApi - User-Agent Header', async () => { body: { count: 0, total: 0, data: [] }, }, }]); - const response = await api.groups.get(); expect(response.status).toBe(200); - mockHttpClient.verifyAllRequestsMade(); }); Deno.test('XmApi - Logging Integration', async () => { const { mockLogger, debugSpy } = createMockLogger(); - const api = new XmApi({ hostname: 'test.xmatters.com', username: 'testuser', @@ -712,7 +690,6 @@ Deno.test('XmApi - Logging Integration', async () => { httpClient: mockHttpClient, logger: mockLogger, }); - mockHttpClient.setReqRes([{ expectedRequest: { method: 'GET', @@ -730,18 +707,14 @@ Deno.test('XmApi - Logging Integration', async () => { body: { count: 0, total: 0, data: [] }, }, }]); - await api.groups.get(); - // Should log request and response const requestLog = debugSpy.calls.find((call) => call.args[0].includes('--> GET https://test.xmatters.com/api/xm/1/groups') ); const responseLog = debugSpy.calls.find((call) => call.args[0].includes('<-- 200')); - expect(requestLog).toBeDefined(); expect(responseLog).toBeDefined(); - mockHttpClient.verifyAllRequestsMade(); }); From 575e747cd722913ba46bee676c7964922953fcf8 Mon Sep 17 00:00:00 2001 From: johan Date: Tue, 24 Jun 2025 23:43:58 -0700 Subject: [PATCH 067/101] Revisit how logs get tested --- src/core/test-utils.ts | 88 ++++++++++++++---- src/index.test.ts | 199 ++++++++++++++++++++++++++++++++++------- 2 files changed, 239 insertions(+), 48 deletions(-) diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts index b308d69..6575817 100644 --- a/src/core/test-utils.ts +++ b/src/core/test-utils.ts @@ -14,7 +14,6 @@ import type { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; import type { Logger } from './types/internal/config.ts'; -import { stub } from 'std/testing/mock.ts'; import { FakeTime } from 'std/testing/time.ts'; import { expect } from 'std/expect/mod.ts'; @@ -104,24 +103,77 @@ export class MockHttpClient implements HttpClient { } /** - * Creates a silent mock logger with pre-configured stubs for call verification. - * Returns both the logger and the stubs for easy access. + * Expected log entry for testing */ -export function createMockLogger() { - const noop = () => {}; - const mockLogger: Logger = { - debug: noop, - info: noop, - warn: noop, - error: noop, - }; - return { - mockLogger, - debugSpy: stub(mockLogger, 'debug', noop), - infoSpy: stub(mockLogger, 'info', noop), - warnSpy: stub(mockLogger, 'warn', noop), - errorSpy: stub(mockLogger, 'error', noop), - }; +interface ExpectedLog { + level: keyof Logger; + message: string | RegExp; +} + +/** + * Mock logger that prevents console output during tests and validates log calls. + * Log calls are validated in order and must match exactly. + */ +export class MockLogger implements Logger { + private expectedLogs: ExpectedLog[] = []; + private logIndex = 0; + public logs: Array<{ level: keyof Logger; message: string }> = []; + + debug(message: string): void { + this.log('debug', message); + } + info(message: string): void { + this.log('info', message); + } + warn(message: string): void { + this.log('warn', message); + } + error(message: string): void { + this.log('error', message); + } + + /** + * Set up expected log calls in order. + * Each actual log call will be validated against the expected log in order. + * Automatically resets any previous expectations. + * + * @param logs Array of expected logs. Each log must have: + * - level: The log level (debug, info, warn, error) + * - message: Either a string for exact match or RegExp for pattern matching + */ + setExpectedLogs(logs: ExpectedLog[]): void { + // Reset state when setting new expectations + this.logs = []; + this.expectedLogs = [...logs]; // Copy to avoid external mutation + } + + verifyAllLogsLogged(): void { + // Only validate if logs were explicitly expected + if (this.expectedLogs.length > 0) { + expect(`log count: ${this.logs.length}`).toBe(`log count: ${this.expectedLogs.length}`); + } + // Auto-reset for next test + this.logs = []; + this.expectedLogs = []; + } + + private log(level: keyof Logger, message: string): void { + // If no expected logs were set, allow any logging (silent mode) + if (this.expectedLogs.length === 0) { + return; + } + this.logs.push({ level, message }); + // Verify we haven't exceeded expected log count + expect(this.logs.length).toBeLessThanOrEqual(this.expectedLogs.length); + const expected = this.expectedLogs[this.logs.length - 1]; + expect(`log level: ${level}`).toBe(`log level: ${expected.level}`); + // Verify message matches (string or RegExp) + if (typeof expected.message === 'string') { + expect(message).toBe(expected.message); + } else { + expect(message).toMatch(expected.message); + } + } } /** diff --git a/src/index.test.ts b/src/index.test.ts index 9f3f3c3..e5d0d59 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,12 +1,11 @@ import { expect } from 'std/expect/mod.ts'; import { XmApi, XmApiError } from './index.ts'; -import { createMockLogger, MockHttpClient, withFakeTime } from './core/test-utils.ts'; +import { MockHttpClient, MockLogger, withFakeTime } from './core/test-utils.ts'; -// Shared mock HTTP client - resets after each test via verifyAllRequestsMade() const mockHttpClient = new MockHttpClient(); +const mockLogger = new MockLogger(); Deno.test('XmApi - Basic Auth Integration', async () => { - const { mockLogger } = createMockLogger(); const api = new XmApi({ hostname: 'test.xmatters.com', username: 'testuser', @@ -39,7 +38,6 @@ Deno.test('XmApi - Basic Auth Integration', async () => { }); Deno.test('XmApi - OAuth Token Integration', async () => { - const { mockLogger } = createMockLogger(); const api = new XmApi({ hostname: 'test.xmatters.com', accessToken: 'test-access-token', @@ -73,7 +71,6 @@ Deno.test('XmApi - OAuth Token Integration', async () => { }); Deno.test('XmApi - Token Refresh on 401', async () => { - const { mockLogger } = createMockLogger(); let tokenRefreshCalled = false; let newAccessToken = ''; let newRefreshToken = ''; @@ -160,7 +157,20 @@ Deno.test('XmApi - Token Refresh on 401', async () => { }); Deno.test('XmApi - Token Refresh Callback Error Handling', async () => { - const { mockLogger, warnSpy } = createMockLogger(); + // Test that callback errors are logged as warnings but don't break the flow + mockLogger.setExpectedLogs([ + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 401 \(\d+ms\)$/ }, + { level: 'debug', message: 'Refreshing token for client test-client-id' }, + { level: 'debug', message: '--> POST https://test.xmatters.com/api/xm/1/oauth2/token' }, + { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, + { + level: 'warn', + message: 'Error in onTokenRefresh callback, but continuing with refreshed token', + }, + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, + ]); const api = new XmApi({ hostname: 'test.xmatters.com', accessToken: 'expired-token', @@ -236,15 +246,28 @@ Deno.test('XmApi - Token Refresh Callback Error Handling', async () => { // Should not throw despite callback error const response = await api.groups.get(); expect(response.status).toBe(200); - // Should log warning about callback error - expect(warnSpy.calls).toHaveLength(1); - expect(warnSpy.calls[0].args[0]).toContain('Error in onTokenRefresh callback'); mockHttpClient.verifyAllRequestsMade(); + mockLogger.verifyAllLogsLogged(); }); Deno.test('XmApi - Retry Logic for 429 Rate Limit', async () => { return await withFakeTime(async (fakeTime) => { - const { mockLogger, debugSpy } = createMockLogger(); + mockLogger.setExpectedLogs([ + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 429 \(\d+ms\)$/ }, + { + level: 'debug', + message: 'Request failed with status 429, retrying in 1000ms (attempt 1/2)', + }, + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 429 \(\d+ms\)$/ }, + { + level: 'debug', + message: 'Request failed with status 429, retrying in 2000ms (attempt 2/2)', + }, + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, + ]); const api = new XmApi({ hostname: 'test.xmatters.com', username: 'testuser', @@ -327,18 +350,23 @@ Deno.test('XmApi - Retry Logic for 429 Rate Limit', async () => { `Original error: ${response.reason}`, ); } - // Verify that retry attempts were logged (2 failures logged before final success) - const debugCalls = debugSpy.calls.filter((call) => - call.args[0].includes('Request failed with status 429') - ); - expect(debugCalls).toHaveLength(2); mockHttpClient.verifyAllRequestsMade(); + mockLogger.verifyAllLogsLogged(); }); }); Deno.test('XmApi - Retry Logic for 500 Server Error', async () => { return await withFakeTime(async (fakeTime) => { - const { mockLogger } = createMockLogger(); + mockLogger.setExpectedLogs([ + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 500 \(\d+ms\)$/ }, + { + level: 'debug', + message: 'Request failed with status 500, retrying in 1000ms (attempt 1/1)', + }, + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, + ]); const api = new XmApi({ hostname: 'test.xmatters.com', username: 'testuser', @@ -403,12 +431,12 @@ Deno.test('XmApi - Retry Logic for 500 Server Error', async () => { ); } mockHttpClient.verifyAllRequestsMade(); + mockLogger.verifyAllLogsLogged(); }); }); Deno.test('XmApi - Max Retries Exceeded', async () => { return await withFakeTime(async (fakeTime) => { - const { mockLogger } = createMockLogger(); const api = new XmApi({ hostname: 'test.xmatters.com', username: 'testuser', @@ -482,7 +510,6 @@ Deno.test('XmApi - Max Retries Exceeded', async () => { }); Deno.test('XmApi - HTTP Error Response Structure', async () => { - const { mockLogger } = createMockLogger(); const api = new XmApi({ hostname: 'test.xmatters.com', username: 'testuser', @@ -525,7 +552,6 @@ Deno.test('XmApi - HTTP Error Response Structure', async () => { }); Deno.test('XmApi - Network Error Handling', async () => { - const { mockLogger } = createMockLogger(); const api = new XmApi({ hostname: 'test.xmatters.com', username: 'testuser', @@ -562,7 +588,6 @@ Deno.test('XmApi - Network Error Handling', async () => { }); Deno.test('XmApi - Custom Headers Integration', async () => { - const { mockLogger } = createMockLogger(); const api = new XmApi({ hostname: 'test.xmatters.com', username: 'testuser', @@ -599,7 +624,6 @@ Deno.test('XmApi - Custom Headers Integration', async () => { }); Deno.test('XmApi - OAuth Token Acquisition', async () => { - const { mockLogger } = createMockLogger(); let tokenRefreshCalled = false; const api = new XmApi({ hostname: 'test.xmatters.com', @@ -651,7 +675,6 @@ Deno.test('XmApi - OAuth Token Acquisition', async () => { }); Deno.test('XmApi - User-Agent Header', async () => { - const { mockLogger } = createMockLogger(); const api = new XmApi({ hostname: 'test.xmatters.com', username: 'testuser', @@ -682,7 +705,11 @@ Deno.test('XmApi - User-Agent Header', async () => { }); Deno.test('XmApi - Logging Integration', async () => { - const { mockLogger, debugSpy } = createMockLogger(); + // Test that logging integration works correctly - validate basic request/response logs + mockLogger.setExpectedLogs([ + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, + ]); const api = new XmApi({ hostname: 'test.xmatters.com', username: 'testuser', @@ -708,14 +735,8 @@ Deno.test('XmApi - Logging Integration', async () => { }, }]); await api.groups.get(); - // Should log request and response - const requestLog = debugSpy.calls.find((call) => - call.args[0].includes('--> GET https://test.xmatters.com/api/xm/1/groups') - ); - const responseLog = debugSpy.calls.find((call) => call.args[0].includes('<-- 200')); - expect(requestLog).toBeDefined(); - expect(responseLog).toBeDefined(); mockHttpClient.verifyAllRequestsMade(); + mockLogger.verifyAllLogsLogged(); }); /* @@ -753,3 +774,121 @@ Deno.test('XmApi - Logging Integration', async () => { + Error handling in token refresh callbacks */ + +/* + +=== DEMO TESTS FOR MAINTAINERS === +These tests showcase the MockLogger pattern matching capabilities, particularly for handling time-dependent logs. + +*/ + +Deno.test('DEMO: MockLogger Pattern Matching - String vs RegExp', async () => { + const mockHttpClient = new MockHttpClient(); + const mockLogger = new MockLogger(); + + // Set up a request that will generate timing logs + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { groups: [] }, + }, + }]); + + // Demo: Mix of exact string matching and pattern matching + mockLogger.setExpectedLogs([ + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, // Exact string match + { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, // Pattern match for timing + ]); + + const config = { + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + }; + + const api = new XmApi(config); + await api.groups.get(); + + // Verify both types of log matching worked + mockLogger.verifyAllLogsLogged(); + mockHttpClient.verifyAllRequestsMade(); +}); + +Deno.test('DEMO: MockLogger Pattern Matching - Complex Timing Patterns', async () => { + const mockHttpClient = new MockHttpClient(); + const mockLogger = new MockLogger(); + + // Set up multiple requests to show various timing patterns + mockHttpClient.setReqRes([ + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { groups: [] }, + }, + }, + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups/test-group', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { id: 'test-group', name: 'Test Group' }, + }, + }, + ]); + + // Demo: Various pattern matching scenarios + mockLogger.setExpectedLogs([ + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, // Any positive duration + { level: 'debug', message: /^--> GET .*groups\/test-group/ }, // Pattern for method + partial URL + { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, // Another timing pattern + ]); + + const config = { + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + }; + + const api = new XmApi(config); + await api.groups.get(); + await api.groups.getByIdentifier('test-group'); + + mockLogger.verifyAllLogsLogged(); + mockHttpClient.verifyAllRequestsMade(); +}); From 49b6f8feb9c58dfb9329390a743b53eae3e4bc36 Mon Sep 17 00:00:00 2001 From: johan Date: Tue, 24 Jun 2025 23:52:07 -0700 Subject: [PATCH 068/101] Simplify MockHttpClient by getting rid of unnecessary requestIndex --- src/core/test-utils.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts index 6575817..c6459e7 100644 --- a/src/core/test-utils.ts +++ b/src/core/test-utils.ts @@ -34,30 +34,26 @@ interface MockRequestResponse { */ export class MockHttpClient implements HttpClient { private requestResponsePairs: MockRequestResponse[] = []; - private requestIndex = 0; public requests: HttpRequest[] = []; send(request: HttpRequest): Promise { this.requests.push(request); - if (this.requestIndex >= this.requestResponsePairs.length) { + if (this.requests.length > this.requestResponsePairs.length) { return Promise.reject( new Error( - `MockHttpClient: Unexpected request #${ - this.requestIndex + 1 - }. Expected ${this.requestResponsePairs.length} requests total.`, + `MockHttpClient: Unexpected request #${this.requests.length}. Expected ${this.requestResponsePairs.length} requests total.`, ), ); } - const currentPair = this.requestResponsePairs[this.requestIndex]; + const currentPair = this.requestResponsePairs[this.requests.length - 1]; this.validateRequest(request, currentPair.expectedRequest); - this.requestIndex++; if (currentPair.mockedError) { return Promise.reject(currentPair.mockedError); } if (!currentPair.mockedResponse) { return Promise.reject( new Error( - `MockHttpClient: Request #${this.requestIndex} must have either mockedResponse or mockedError`, + `MockHttpClient: Request #${this.requests.length} must have either mockedResponse or mockedError`, ), ); } @@ -71,7 +67,6 @@ export class MockHttpClient implements HttpClient { */ setReqRes(pairs: MockRequestResponse[]): void { this.requestResponsePairs = [...pairs]; // Copy to avoid external mutation - this.requestIndex = 0; } /** @@ -80,15 +75,14 @@ export class MockHttpClient implements HttpClient { * Automatically resets the client for the next test. */ verifyAllRequestsMade(): void { - if (this.requestIndex < this.requestResponsePairs.length) { + if (this.requests.length < this.requestResponsePairs.length) { throw new Error( - `MockHttpClient: Expected ${this.requestResponsePairs.length} requests, but only ${this.requestIndex} were made.`, + `MockHttpClient: Expected ${this.requestResponsePairs.length} requests, but only ${this.requests.length} were made.`, ); } // Auto-reset for next test this.requests = []; this.requestResponsePairs = []; - this.requestIndex = 0; } private validateRequest( @@ -116,7 +110,6 @@ interface ExpectedLog { */ export class MockLogger implements Logger { private expectedLogs: ExpectedLog[] = []; - private logIndex = 0; public logs: Array<{ level: keyof Logger; message: string }> = []; debug(message: string): void { From cc92b739074ad3b704e77364ec3195f8fedd317d Mon Sep 17 00:00:00 2001 From: johan Date: Wed, 25 Jun 2025 00:14:58 -0700 Subject: [PATCH 069/101] Small improvements to MockHttpClient --- src/core/test-utils.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts index c6459e7..e8ad7a4 100644 --- a/src/core/test-utils.ts +++ b/src/core/test-utils.ts @@ -66,6 +66,8 @@ export class MockHttpClient implements HttpClient { * Responses/errors are returned in the same order as the pairs are defined. */ setReqRes(pairs: MockRequestResponse[]): void { + // Auto-reset for next test + this.requests = []; this.requestResponsePairs = [...pairs]; // Copy to avoid external mutation } @@ -75,11 +77,9 @@ export class MockHttpClient implements HttpClient { * Automatically resets the client for the next test. */ verifyAllRequestsMade(): void { - if (this.requests.length < this.requestResponsePairs.length) { - throw new Error( - `MockHttpClient: Expected ${this.requestResponsePairs.length} requests, but only ${this.requests.length} were made.`, - ); - } + expect(`request count: ${this.requests.length}`).toBe( + `request count: ${this.requestResponsePairs.length}`, + ); // Auto-reset for next test this.requests = []; this.requestResponsePairs = []; From 41ada79dba513b9d63942b06a4045762aba94ba5 Mon Sep 17 00:00:00 2001 From: johan Date: Wed, 25 Jun 2025 00:21:42 -0700 Subject: [PATCH 070/101] reformat src/index.test.ts --- src/index.test.ts | 1423 +++++++++++++++++++++++---------------------- 1 file changed, 715 insertions(+), 708 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index e5d0d59..0d7c4ef 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -5,137 +5,55 @@ import { MockHttpClient, MockLogger, withFakeTime } from './core/test-utils.ts'; const mockHttpClient = new MockHttpClient(); const mockLogger = new MockLogger(); -Deno.test('XmApi - Basic Auth Integration', async () => { - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, - }); - // Test a simple GET request - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups?limit=10', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', // base64 of testuser:testpass - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, - }, - mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: { count: 0, total: 0, data: [] }, - }, - }]); - const response = await api.groups.get({ query: { limit: 10 } }); - expect(response.status).toBe(200); - expect(response.body.count).toBe(0); - mockHttpClient.verifyAllRequestsMade(); -}); - -Deno.test('XmApi - OAuth Token Integration', async () => { - const api = new XmApi({ - hostname: 'test.xmatters.com', - accessToken: 'test-access-token', - refreshToken: 'test-refresh-token', - clientId: 'test-client-id', - httpClient: mockHttpClient, - logger: mockLogger, - }); - // Test OAuth Bearer token is used - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Bearer test-access-token', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, - }, - mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: { count: 1, total: 1, data: [{ id: '123', targetName: 'Test Group' }] }, - }, - }]); - const response = await api.groups.get(); - expect(response.status).toBe(200); - expect(response.body.data).toHaveLength(1); - mockHttpClient.verifyAllRequestsMade(); -}); - -Deno.test('XmApi - Token Refresh on 401', async () => { - let tokenRefreshCalled = false; - let newAccessToken = ''; - let newRefreshToken = ''; - const api = new XmApi({ - hostname: 'test.xmatters.com', - accessToken: 'expired-token', - refreshToken: 'valid-refresh-token', - clientId: 'test-client-id', - httpClient: mockHttpClient, - logger: mockLogger, - onTokenRefresh: (accessToken, refreshToken) => { - tokenRefreshCalled = true; - newAccessToken = accessToken; - newRefreshToken = refreshToken; - }, - }); - mockHttpClient.setReqRes([ - // First request fails with 401 - { +Deno.test('XmApi', async (t) => { + await t.step('Basic Auth Integration', async () => { + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + }); + // Test a simple GET request + mockHttpClient.setReqRes([{ expectedRequest: { method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', + url: 'https://test.xmatters.com/api/xm/1/groups?limit=10', headers: { - 'Authorization': 'Bearer expired-token', + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', // base64 of testuser:testpass 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, - mockedResponse: { - status: 401, - headers: { 'Content-Type': 'application/json' }, - body: { error: 'Unauthorized' }, - }, - }, - // Token refresh request - { - expectedRequest: { - method: 'POST', - url: 'https://test.xmatters.com/api/xm/1/oauth2/token', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, - body: 'grant_type=refresh_token&refresh_token=valid-refresh-token&client_id=test-client-id', - }, mockedResponse: { status: 200, headers: { 'Content-Type': 'application/json' }, - body: { - access_token: 'new-access-token', - refresh_token: 'new-refresh-token', - token_type: 'bearer', - expires_in: 3600, - }, + body: { count: 0, total: 0, data: [] }, }, - }, - // Retry original request with new token - { + }]); + const response = await api.groups.get({ query: { limit: 10 } }); + expect(response.status).toBe(200); + expect(response.body.count).toBe(0); + mockHttpClient.verifyAllRequestsMade(); + }); + + await t.step('OAuth Token Integration', async () => { + const api = new XmApi({ + hostname: 'test.xmatters.com', + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + clientId: 'test-client-id', + httpClient: mockHttpClient, + logger: mockLogger, + }); + // Test OAuth Bearer token is used + mockHttpClient.setReqRes([{ expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', headers: { - 'Authorization': 'Bearer new-access-token', + 'Authorization': 'Bearer test-access-token', 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', @@ -146,180 +64,80 @@ Deno.test('XmApi - Token Refresh on 401', async () => { headers: { 'Content-Type': 'application/json' }, body: { count: 1, total: 1, data: [{ id: '123', targetName: 'Test Group' }] }, }, - }, - ]); - const response = await api.groups.get(); - expect(response.status).toBe(200); - expect(tokenRefreshCalled).toBe(true); - expect(newAccessToken).toBe('new-access-token'); - expect(newRefreshToken).toBe('new-refresh-token'); - mockHttpClient.verifyAllRequestsMade(); -}); - -Deno.test('XmApi - Token Refresh Callback Error Handling', async () => { - // Test that callback errors are logged as warnings but don't break the flow - mockLogger.setExpectedLogs([ - { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, - { level: 'debug', message: /^<-- 401 \(\d+ms\)$/ }, - { level: 'debug', message: 'Refreshing token for client test-client-id' }, - { level: 'debug', message: '--> POST https://test.xmatters.com/api/xm/1/oauth2/token' }, - { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, - { - level: 'warn', - message: 'Error in onTokenRefresh callback, but continuing with refreshed token', - }, - { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, - { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, - ]); - const api = new XmApi({ - hostname: 'test.xmatters.com', - accessToken: 'expired-token', - refreshToken: 'valid-refresh-token', - clientId: 'test-client-id', - httpClient: mockHttpClient, - logger: mockLogger, - onTokenRefresh: () => { - throw new Error('Callback error'); - }, + }]); + const response = await api.groups.get(); + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(1); + mockHttpClient.verifyAllRequestsMade(); }); - mockHttpClient.setReqRes([ - // First request fails with 401 - { - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Bearer expired-token', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, - }, - mockedResponse: { - status: 401, - headers: { 'Content-Type': 'application/json' }, - body: { error: 'Unauthorized' }, - }, - }, - // Token refresh request - { - expectedRequest: { - method: 'POST', - url: 'https://test.xmatters.com/api/xm/1/oauth2/token', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, - body: 'grant_type=refresh_token&refresh_token=valid-refresh-token&client_id=test-client-id', - }, - mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: { - access_token: 'new-access-token', - refresh_token: 'new-refresh-token', - token_type: 'bearer', - expires_in: 3600, - }, - }, - }, - // Retry original request with new token - { - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Bearer new-access-token', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, - }, - mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: { count: 0, total: 0, data: [] }, - }, - }, - ]); - // Should not throw despite callback error - const response = await api.groups.get(); - expect(response.status).toBe(200); - mockHttpClient.verifyAllRequestsMade(); - mockLogger.verifyAllLogsLogged(); -}); -Deno.test('XmApi - Retry Logic for 429 Rate Limit', async () => { - return await withFakeTime(async (fakeTime) => { - mockLogger.setExpectedLogs([ - { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, - { level: 'debug', message: /^<-- 429 \(\d+ms\)$/ }, - { - level: 'debug', - message: 'Request failed with status 429, retrying in 1000ms (attempt 1/2)', - }, - { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, - { level: 'debug', message: /^<-- 429 \(\d+ms\)$/ }, - { - level: 'debug', - message: 'Request failed with status 429, retrying in 2000ms (attempt 2/2)', - }, - { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, - { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, - ]); + await t.step('Token Refresh on 401', async () => { + let tokenRefreshCalled = false; + let newAccessToken = ''; + let newRefreshToken = ''; const api = new XmApi({ hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', + accessToken: 'expired-token', + refreshToken: 'valid-refresh-token', + clientId: 'test-client-id', httpClient: mockHttpClient, logger: mockLogger, - maxRetries: 2, + onTokenRefresh: (accessToken, refreshToken) => { + tokenRefreshCalled = true; + newAccessToken = accessToken; + newRefreshToken = refreshToken; + }, }); mockHttpClient.setReqRes([ - // First request fails with 429 rate limit + // First request fails with 401 { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Authorization': 'Bearer expired-token', 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, mockedResponse: { - status: 429, - headers: { 'Retry-After': '1' }, // Server requests 1 second delay - body: { error: 'Rate limit exceeded' }, + status: 401, + headers: { 'Content-Type': 'application/json' }, + body: { error: 'Unauthorized' }, }, }, - // First retry also fails with 429 rate limit + // Token refresh request { expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', }, + body: + 'grant_type=refresh_token&refresh_token=valid-refresh-token&client_id=test-client-id', }, mockedResponse: { - status: 429, - headers: { 'Retry-After': '1' }, - body: { error: 'Rate limit exceeded' }, + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'bearer', + expires_in: 3600, + }, }, }, - // Second retry succeeds + // Retry original request with new token { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Authorization': 'Bearer new-access-token', 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', @@ -328,512 +146,584 @@ Deno.test('XmApi - Retry Logic for 429 Rate Limit', async () => { mockedResponse: { status: 200, headers: { 'Content-Type': 'application/json' }, - body: { count: 0, total: 0, data: [] }, + body: { count: 1, total: 1, data: [{ id: '123', targetName: 'Test Group' }] }, }, }, ]); - // Start the request without awaiting to allow fake time control - const requestPromise = api.groups.get(); - const [response] = await Promise.allSettled([ - requestPromise, - // Advance fake time to trigger scheduled retry delays - // Pattern: request -> setTimeout for retry delay -> retry -> setTimeout -> retry - fakeTime.nextAsync(), // Executes first setTimeout (1s delay), triggering first retry - fakeTime.nextAsync(), // Executes second setTimeout (1s delay), triggering second retry - ]); - if (response.status === 'fulfilled') { - expect(response.value.status).toBe(200); - } else { - throw new Error( - `TEST SETUP ERROR: Expected request to succeed for retry logic test, but it was rejected. ` + - `This likely indicates a problem with the test setup (mock expectations, fake time, etc.). ` + - `Original error: ${response.reason}`, - ); - } + const response = await api.groups.get(); + expect(response.status).toBe(200); + expect(tokenRefreshCalled).toBe(true); + expect(newAccessToken).toBe('new-access-token'); + expect(newRefreshToken).toBe('new-refresh-token'); mockHttpClient.verifyAllRequestsMade(); - mockLogger.verifyAllLogsLogged(); }); -}); -Deno.test('XmApi - Retry Logic for 500 Server Error', async () => { - return await withFakeTime(async (fakeTime) => { + await t.step('Token Refresh Callback Error Handling', async () => { + // Test that callback errors are logged as warnings but don't break the flow mockLogger.setExpectedLogs([ { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, - { level: 'debug', message: /^<-- 500 \(\d+ms\)$/ }, + { level: 'debug', message: /^<-- 401 \(\d+ms\)$/ }, + { level: 'debug', message: 'Refreshing token for client test-client-id' }, + { level: 'debug', message: '--> POST https://test.xmatters.com/api/xm/1/oauth2/token' }, + { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, { - level: 'debug', - message: 'Request failed with status 500, retrying in 1000ms (attempt 1/1)', + level: 'warn', + message: 'Error in onTokenRefresh callback, but continuing with refreshed token', }, { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, ]); const api = new XmApi({ hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', + accessToken: 'expired-token', + refreshToken: 'valid-refresh-token', + clientId: 'test-client-id', httpClient: mockHttpClient, logger: mockLogger, - maxRetries: 1, + onTokenRefresh: () => { + throw new Error('Callback error'); + }, }); mockHttpClient.setReqRes([ - // First request fails with 500 server error + // First request fails with 401 { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Authorization': 'Bearer expired-token', 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, mockedResponse: { - status: 500, + status: 401, headers: { 'Content-Type': 'application/json' }, - body: { error: 'Internal Server Error' }, + body: { error: 'Unauthorized' }, }, }, - // Retry succeeds after exponential backoff delay + // Token refresh request { expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', }, + body: + 'grant_type=refresh_token&refresh_token=valid-refresh-token&client_id=test-client-id', }, mockedResponse: { status: 200, headers: { 'Content-Type': 'application/json' }, - body: { count: 0, total: 0, data: [] }, - }, - }, - ]); - // Start the request without awaiting to allow fake time control - const requestPromise = api.groups.get(); - const [response] = await Promise.allSettled([ - requestPromise, - // Advance fake time to trigger scheduled retry delay - // Pattern: request -> setTimeout for exponential backoff -> retry - fakeTime.nextAsync(), // Executes setTimeout (1s exponential backoff), triggering retry - ]); - if (response.status === 'fulfilled') { - expect(response.value.status).toBe(200); - } else { - throw new Error( - `TEST SETUP ERROR: Expected request to succeed for 500 server error retry test, but it was rejected. ` + - `This likely indicates a problem with the test setup (mock expectations, fake time, etc.). ` + - `Original error: ${response.reason}`, - ); - } - mockHttpClient.verifyAllRequestsMade(); - mockLogger.verifyAllLogsLogged(); - }); -}); - -Deno.test('XmApi - Max Retries Exceeded', async () => { - return await withFakeTime(async (fakeTime) => { - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, - maxRetries: 1, - }); - mockHttpClient.setReqRes([ - // First request fails with 500 server error - { - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + body: { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'bearer', + expires_in: 3600, }, }, - mockedResponse: { - status: 500, - headers: { 'Content-Type': 'application/json' }, - body: { reason: 'Internal Server Error' }, - }, }, - // Retry also fails with 500, exhausting maxRetries (1) + // Retry original request with new token { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Authorization': 'Bearer new-access-token', 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, mockedResponse: { - status: 500, + status: 200, headers: { 'Content-Type': 'application/json' }, - body: { reason: 'Internal Server Error' }, + body: { count: 0, total: 0, data: [] }, }, }, ]); - // Start the request without awaiting to allow fake time control - const requestPromise = api.groups.get(); - const [result] = await Promise.allSettled([ - requestPromise, - // Advance fake time to process retry attempts until max retries exceeded - // Pattern: request -> setTimeout for exponential backoff -> retry -> throw error - fakeTime.nextAsync(), // Executes setTimeout (1s exponential backoff), triggering retry that also fails - ]); - if (result.status === 'rejected') { - const error = result.reason; - expect(error).toBeInstanceOf(XmApiError); - const apiError = error as XmApiError; - // Should throw with the extracted error message from the response - expect(apiError.message).toBe('Internal Server Error'); - expect(apiError.response?.status).toBe(500); - } else { - throw new Error( - `TEST SETUP ERROR: Expected request to fail after max retries exceeded, but it succeeded. ` + - `This likely indicates a problem with the test setup (mock expectations, fake time, etc.). ` + - `Actual response: ${JSON.stringify(result.value)}`, - ); - } + // Should not throw despite callback error + const response = await api.groups.get(); + expect(response.status).toBe(200); mockHttpClient.verifyAllRequestsMade(); + mockLogger.verifyAllLogsLogged(); }); -}); -Deno.test('XmApi - HTTP Error Response Structure', async () => { - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, + await t.step('Retry Logic for 429 Rate Limit', async () => { + return await withFakeTime(async (fakeTime) => { + mockLogger.setExpectedLogs([ + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 429 \(\d+ms\)$/ }, + { + level: 'debug', + message: 'Request failed with status 429, retrying in 1000ms (attempt 1/2)', + }, + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 429 \(\d+ms\)$/ }, + { + level: 'debug', + message: 'Request failed with status 429, retrying in 2000ms (attempt 2/2)', + }, + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, + ]); + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + maxRetries: 2, + }); + mockHttpClient.setReqRes([ + // First request fails with 429 rate limit + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 429, + headers: { 'Retry-After': '1' }, // Server requests 1 second delay + body: { error: 'Rate limit exceeded' }, + }, + }, + // First retry also fails with 429 rate limit + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 429, + headers: { 'Retry-After': '1' }, + body: { error: 'Rate limit exceeded' }, + }, + }, + // Second retry succeeds + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { count: 0, total: 0, data: [] }, + }, + }, + ]); + // Start the request without awaiting to allow fake time control + const requestPromise = api.groups.get(); + const [response] = await Promise.allSettled([ + requestPromise, + // Advance fake time to trigger scheduled retry delays + // Pattern: request -> setTimeout for retry delay -> retry -> setTimeout -> retry + fakeTime.nextAsync(), // Executes first setTimeout (1s delay), triggering first retry + fakeTime.nextAsync(), // Executes second setTimeout (1s delay), triggering second retry + ]); + if (response.status === 'fulfilled') { + expect(response.value.status).toBe(200); + } else { + throw new Error( + `TEST SETUP ERROR: Expected request to succeed for retry logic test, but it was rejected. ` + + `This likely indicates a problem with the test setup (mock expectations, fake time, etc.). ` + + `Original error: ${response.reason}`, + ); + } + mockHttpClient.verifyAllRequestsMade(); + mockLogger.verifyAllLogsLogged(); + }); + }); + + await t.step('Retry Logic for 500 Server Error', async () => { + return await withFakeTime(async (fakeTime) => { + mockLogger.setExpectedLogs([ + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 500 \(\d+ms\)$/ }, + { + level: 'debug', + message: 'Request failed with status 500, retrying in 1000ms (attempt 1/1)', + }, + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, + ]); + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + maxRetries: 1, + }); + mockHttpClient.setReqRes([ + // First request fails with 500 server error + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 500, + headers: { 'Content-Type': 'application/json' }, + body: { error: 'Internal Server Error' }, + }, + }, + // Retry succeeds after exponential backoff delay + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { count: 0, total: 0, data: [] }, + }, + }, + ]); + // Start the request without awaiting to allow fake time control + const requestPromise = api.groups.get(); + const [response] = await Promise.allSettled([ + requestPromise, + // Advance fake time to trigger scheduled retry delay + // Pattern: request -> setTimeout for exponential backoff -> retry + fakeTime.nextAsync(), // Executes setTimeout (1s exponential backoff), triggering retry + ]); + if (response.status === 'fulfilled') { + expect(response.value.status).toBe(200); + } else { + throw new Error( + `TEST SETUP ERROR: Expected request to succeed for 500 server error retry test, but it was rejected. ` + + `This likely indicates a problem with the test setup (mock expectations, fake time, etc.). ` + + `Original error: ${response.reason}`, + ); + } + mockHttpClient.verifyAllRequestsMade(); + mockLogger.verifyAllLogsLogged(); + }); }); - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups/nonexistent', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, - }, - mockedResponse: { - status: 404, - headers: { 'Content-Type': 'application/json' }, - body: { error: 'Group not found', code: 'GROUP_NOT_FOUND' }, - }, - }]); - // Test HTTP error response handling (server responds with 404 status) - // This tests a different scenario than network errors - here the server successfully - // responds but with an error status code, so XmApiError should contain response details - try { - await api.groups.getByIdentifier('nonexistent'); - } catch (error) { - const apiError = error as XmApiError; - expect(apiError).toBeInstanceOf(XmApiError); - expect(apiError.response).toBeDefined(); - expect(apiError.response?.status).toBe(404); - expect(apiError.response?.body).toEqual({ error: 'Group not found', code: 'GROUP_NOT_FOUND' }); - expect(apiError.response?.headers).toEqual({ 'Content-Type': 'application/json' }); - } finally { - mockHttpClient.verifyAllRequestsMade(); - } -}); -Deno.test('XmApi - Network Error Handling', async () => { - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, + await t.step('Max Retries Exceeded', async () => { + return await withFakeTime(async (fakeTime) => { + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + maxRetries: 1, + }); + mockHttpClient.setReqRes([ + // First request fails with 500 server error + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 500, + headers: { 'Content-Type': 'application/json' }, + body: { reason: 'Internal Server Error' }, + }, + }, + // Retry also fails with 500, exhausting maxRetries (1) + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 500, + headers: { 'Content-Type': 'application/json' }, + body: { reason: 'Internal Server Error' }, + }, + }, + ]); + // Start the request without awaiting to allow fake time control + const requestPromise = api.groups.get(); + const [result] = await Promise.allSettled([ + requestPromise, + // Advance fake time to process retry attempts until max retries exceeded + // Pattern: request -> setTimeout for exponential backoff -> retry -> throw error + fakeTime.nextAsync(), // Executes setTimeout (1s exponential backoff), triggering retry that also fails + ]); + if (result.status === 'rejected') { + const error = result.reason; + expect(error).toBeInstanceOf(XmApiError); + const apiError = error as XmApiError; + // Should throw with the extracted error message from the response + expect(apiError.message).toBe('Internal Server Error'); + expect(apiError.response?.status).toBe(500); + } else { + throw new Error( + `TEST SETUP ERROR: Expected request to fail after max retries exceeded, but it succeeded. ` + + `This likely indicates a problem with the test setup (mock expectations, fake time, etc.). ` + + `Actual response: ${JSON.stringify(result.value)}`, + ); + } + mockHttpClient.verifyAllRequestsMade(); + }); }); - // Use mockedError to simulate network connection failure - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + + await t.step('HTTP Error Response Structure', async () => { + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + }); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups/nonexistent', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, }, - }, - mockedError: new Error('Network connection failed'), - }]); - // MockHttpClient with mockedError will always reject, so we can test error handling directly - try { - await api.groups.get(); - } catch (error) { - const apiError = error as XmApiError; - expect(apiError).toBeInstanceOf(XmApiError); - expect(apiError.message).toBe('Request failed'); - expect(apiError.response).toBeNull(); - expect((apiError.cause as Error)?.message).toBe('Network connection failed'); - } finally { - mockHttpClient.verifyAllRequestsMade(); - } -}); + mockedResponse: { + status: 404, + headers: { 'Content-Type': 'application/json' }, + body: { error: 'Group not found', code: 'GROUP_NOT_FOUND' }, + }, + }]); + // Test HTTP error response handling (server responds with 404 status) + // This tests a different scenario than network errors - here the server successfully + // responds but with an error status code, so XmApiError should contain response details + try { + await api.groups.getByIdentifier('nonexistent'); + } catch (error) { + const apiError = error as XmApiError; + expect(apiError).toBeInstanceOf(XmApiError); + expect(apiError.response).toBeDefined(); + expect(apiError.response?.status).toBe(404); + expect(apiError.response?.body).toEqual({ + error: 'Group not found', + code: 'GROUP_NOT_FOUND', + }); + expect(apiError.response?.headers).toEqual({ 'Content-Type': 'application/json' }); + } finally { + mockHttpClient.verifyAllRequestsMade(); + } + }); -Deno.test('XmApi - Custom Headers Integration', async () => { - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, - defaultHeaders: { - 'X-Custom-Header': 'custom-value', - 'X-Client-Version': '1.0.0', - }, + await t.step('Network Error Handling', async () => { + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + }); + // Use mockedError to simulate network connection failure + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedError: new Error('Network connection failed'), + }]); + // MockHttpClient with mockedError will always reject, so we can test error handling directly + try { + await api.groups.get(); + } catch (error) { + const apiError = error as XmApiError; + expect(apiError).toBeInstanceOf(XmApiError); + expect(apiError.message).toBe('Request failed'); + expect(apiError.response).toBeNull(); + expect((apiError.cause as Error)?.message).toBe('Network connection failed'); + } finally { + mockHttpClient.verifyAllRequestsMade(); + } }); - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + + await t.step('Custom Headers Integration', async () => { + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + defaultHeaders: { 'X-Custom-Header': 'custom-value', 'X-Client-Version': '1.0.0', }, - }, - mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: { count: 0, total: 0, data: [] }, - }, - }]); - const response = await api.groups.get(); - expect(response.status).toBe(200); - mockHttpClient.verifyAllRequestsMade(); -}); - -Deno.test('XmApi - OAuth Token Acquisition', async () => { - let tokenRefreshCalled = false; - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, - onTokenRefresh: (accessToken, refreshToken) => { - tokenRefreshCalled = true; - expect(accessToken).toBe('obtained-access-token'); - expect(refreshToken).toBe('obtained-refresh-token'); - }, - }); - // We'll validate the URL params more flexibly since URLSearchParams order can vary - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'POST', - url: 'https://test.xmatters.com/api/xm/1/oauth2/token', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + }); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + 'X-Custom-Header': 'custom-value', + 'X-Client-Version': '1.0.0', + }, }, - body: 'grant_type=password&client_id=test-client&username=testuser&password=testpass', - }, - mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: { - access_token: 'obtained-access-token', - refresh_token: 'obtained-refresh-token', - token_type: 'bearer', - expires_in: 3600, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { count: 0, total: 0, data: [] }, }, - }, - }]); - const response = await api.oauth.obtainTokens({ clientId: 'test-client' }); - expect(response.status).toBe(200); - expect(response.body.access_token).toBe('obtained-access-token'); - expect(tokenRefreshCalled).toBe(true); - // Validate that the request body contains the expected parameters - const request = mockHttpClient.requests[0]; - const bodyString = request.body as string; - expect(bodyString).toContain('grant_type=password'); - expect(bodyString).toContain('username=testuser'); - expect(bodyString).toContain('password=testpass'); - expect(bodyString).toContain('client_id=test-client'); - mockHttpClient.verifyAllRequestsMade(); -}); - -Deno.test('XmApi - User-Agent Header', async () => { - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, + }]); + const response = await api.groups.get(); + expect(response.status).toBe(200); + mockHttpClient.verifyAllRequestsMade(); }); - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', // Should match version in deno.json - }, - }, - mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: { count: 0, total: 0, data: [] }, - }, - }]); - const response = await api.groups.get(); - expect(response.status).toBe(200); - mockHttpClient.verifyAllRequestsMade(); -}); -Deno.test('XmApi - Logging Integration', async () => { - // Test that logging integration works correctly - validate basic request/response logs - mockLogger.setExpectedLogs([ - { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, - { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, - ]); - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, - }); - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + await t.step('OAuth Token Acquisition', async () => { + let tokenRefreshCalled = false; + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + onTokenRefresh: (accessToken, refreshToken) => { + tokenRefreshCalled = true; + expect(accessToken).toBe('obtained-access-token'); + expect(refreshToken).toBe('obtained-refresh-token'); }, - }, - mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: { count: 0, total: 0, data: [] }, - }, - }]); - await api.groups.get(); - mockHttpClient.verifyAllRequestsMade(); - mockLogger.verifyAllLogsLogged(); -}); - -/* - -1. Authentication Integration: - + Basic Auth with proper header encoding - + OAuth Bearer token authentication - + Token refresh on 401 responses - + Token refresh callback handling with error safety - -2. HTTP Client Integration: - + Request building and sending through injected HTTP client - + Custom headers merging (default + per-request) - + User-Agent header generation from deno.json version - -3. Retry Logic: - + 429 rate limit retries with Retry-After header respect - + 500 server error retries with exponential backoff - + Maximum retry attempts enforcement - + Proper error handling after max retries exceeded - -4. Logging Integration: - + Request/response logging through injected logger - + Debug logging for retry attempts - + Warning logging for token refresh callback errors - -5. Error Handling: - + Proper XmApiError instances with response details - + Network error handling with cause preservation - + Consistent error structure for consumers - -6. OAuth Token Management: - + Token acquisition from basic auth credentials - + Token refresh callback execution - + Error handling in token refresh callbacks - -*/ - -/* - -=== DEMO TESTS FOR MAINTAINERS === -These tests showcase the MockLogger pattern matching capabilities, particularly for handling time-dependent logs. - -*/ - -Deno.test('DEMO: MockLogger Pattern Matching - String vs RegExp', async () => { - const mockHttpClient = new MockHttpClient(); - const mockLogger = new MockLogger(); - - // Set up a request that will generate timing logs - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + }); + // We'll validate the URL params more flexibly since URLSearchParams order can vary + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + body: 'grant_type=password&client_id=test-client&username=testuser&password=testpass', }, - }, - mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: { groups: [] }, - }, - }]); - - // Demo: Mix of exact string matching and pattern matching - mockLogger.setExpectedLogs([ - { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, // Exact string match - { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, // Pattern match for timing - ]); - - const config = { - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, - }; - - const api = new XmApi(config); - await api.groups.get(); - - // Verify both types of log matching worked - mockLogger.verifyAllLogsLogged(); - mockHttpClient.verifyAllRequestsMade(); -}); + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { + access_token: 'obtained-access-token', + refresh_token: 'obtained-refresh-token', + token_type: 'bearer', + expires_in: 3600, + }, + }, + }]); + const response = await api.oauth.obtainTokens({ clientId: 'test-client' }); + expect(response.status).toBe(200); + expect(response.body.access_token).toBe('obtained-access-token'); + expect(tokenRefreshCalled).toBe(true); + // Validate that the request body contains the expected parameters + const request = mockHttpClient.requests[0]; + const bodyString = request.body as string; + expect(bodyString).toContain('grant_type=password'); + expect(bodyString).toContain('username=testuser'); + expect(bodyString).toContain('password=testpass'); + expect(bodyString).toContain('client_id=test-client'); + mockHttpClient.verifyAllRequestsMade(); + }); -Deno.test('DEMO: MockLogger Pattern Matching - Complex Timing Patterns', async () => { - const mockHttpClient = new MockHttpClient(); - const mockLogger = new MockLogger(); + await t.step('User-Agent Header', async () => { + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + }); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', // Should match version in deno.json + }, + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { count: 0, total: 0, data: [] }, + }, + }]); + const response = await api.groups.get(); + expect(response.status).toBe(200); + mockHttpClient.verifyAllRequestsMade(); + }); - // Set up multiple requests to show various timing patterns - mockHttpClient.setReqRes([ - { + await t.step('Logging Integration', async () => { + // Test that logging integration works correctly - validate basic request/response logs + mockLogger.setExpectedLogs([ + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, + ]); + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + }); + mockHttpClient.setReqRes([{ expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', @@ -847,13 +737,66 @@ Deno.test('DEMO: MockLogger Pattern Matching - Complex Timing Patterns', async ( mockedResponse: { status: 200, headers: { 'Content-Type': 'application/json' }, - body: { groups: [] }, + body: { count: 0, total: 0, data: [] }, }, - }, - { + }]); + await api.groups.get(); + mockHttpClient.verifyAllRequestsMade(); + mockLogger.verifyAllLogsLogged(); + }); + + /* + + 1. Authentication Integration: + + Basic Auth with proper header encoding + + OAuth Bearer token authentication + + Token refresh on 401 responses + + Token refresh callback handling with error safety + + 2. HTTP Client Integration: + + Request building and sending through injected HTTP client + + Custom headers merging (default + per-request) + + User-Agent header generation from deno.json version + + 3. Retry Logic: + + 429 rate limit retries with Retry-After header respect + + 500 server error retries with exponential backoff + + Maximum retry attempts enforcement + + Proper error handling after max retries exceeded + + 4. Logging Integration: + + Request/response logging through injected logger + + Debug logging for retry attempts + + Warning logging for token refresh callback errors + + 5. Error Handling: + + Proper XmApiError instances with response details + + Network error handling with cause preservation + + Consistent error structure for consumers + + 6. OAuth Token Management: + + Token acquisition from basic auth credentials + + Token refresh callback execution + + Error handling in token refresh callbacks + + */ + + /* + + === DEMO TESTS FOR MAINTAINERS === + These tests showcase the MockLogger pattern matching capabilities, particularly for handling time-dependent logs. + + */ + + Deno.test('DEMO: MockLogger Pattern Matching - String vs RegExp', async () => { + const mockHttpClient = new MockHttpClient(); + const mockLogger = new MockLogger(); + + // Set up a request that will generate timing logs + mockHttpClient.setReqRes([{ expectedRequest: { method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups/test-group', + url: 'https://test.xmatters.com/api/xm/1/groups', headers: { 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', 'Content-Type': 'application/json', @@ -864,31 +807,95 @@ Deno.test('DEMO: MockLogger Pattern Matching - Complex Timing Patterns', async ( mockedResponse: { status: 200, headers: { 'Content-Type': 'application/json' }, - body: { id: 'test-group', name: 'Test Group' }, + body: { groups: [] }, + }, + }]); + + // Demo: Mix of exact string matching and pattern matching + mockLogger.setExpectedLogs([ + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, // Exact string match + { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, // Pattern match for timing + ]); + + const config = { + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + }; + + const api = new XmApi(config); + await api.groups.get(); + + // Verify both types of log matching worked + mockLogger.verifyAllLogsLogged(); + mockHttpClient.verifyAllRequestsMade(); + }); + + Deno.test('DEMO: MockLogger Pattern Matching - Complex Timing Patterns', async () => { + const mockHttpClient = new MockHttpClient(); + const mockLogger = new MockLogger(); + + // Set up multiple requests to show various timing patterns + mockHttpClient.setReqRes([ + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { groups: [] }, + }, + }, + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups/test-group', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { id: 'test-group', name: 'Test Group' }, + }, }, - }, - ]); - - // Demo: Various pattern matching scenarios - mockLogger.setExpectedLogs([ - { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, - { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, // Any positive duration - { level: 'debug', message: /^--> GET .*groups\/test-group/ }, // Pattern for method + partial URL - { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, // Another timing pattern - ]); - - const config = { - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, - }; - - const api = new XmApi(config); - await api.groups.get(); - await api.groups.getByIdentifier('test-group'); - - mockLogger.verifyAllLogsLogged(); - mockHttpClient.verifyAllRequestsMade(); + ]); + + // Demo: Various pattern matching scenarios + mockLogger.setExpectedLogs([ + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, // Any positive duration + { level: 'debug', message: /^--> GET .*groups\/test-group/ }, // Pattern for method + partial URL + { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, // Another timing pattern + ]); + + const config = { + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + }; + + const api = new XmApi(config); + await api.groups.get(); + await api.groups.getByIdentifier('test-group'); + + mockLogger.verifyAllLogsLogged(); + mockHttpClient.verifyAllRequestsMade(); + }); }); From b82d14102bc2d0f4c4f95036a7e08f9e8459d790 Mon Sep 17 00:00:00 2001 From: johan Date: Wed, 25 Jun 2025 22:46:35 -0700 Subject: [PATCH 071/101] Continue refactoring unit tests - remove the ones made redundant by the primary test file --- src/core/request-builder.test.ts | 524 ++++++++++---------- src/core/request-handler.test.ts | 752 ----------------------------- src/core/resource-client.test.ts | 304 ++++++++---- src/core/test-utils.ts | 2 +- src/endpoints/groups/index.test.ts | 6 +- src/index.test.ts | 210 ++++---- 6 files changed, 574 insertions(+), 1224 deletions(-) delete mode 100644 src/core/request-handler.test.ts diff --git a/src/core/request-builder.test.ts b/src/core/request-builder.test.ts index 1fee631..ecd602b 100644 --- a/src/core/request-builder.test.ts +++ b/src/core/request-builder.test.ts @@ -47,184 +47,265 @@ const mockCustomHeadersOptions: RequestBuildOptions = { }; Deno.test('RequestBuilder', async (t) => { - await t.step('builds request with relative path - verifies correct URL construction', () => { - const { builder } = createRequestBuilderTestSetup(); - const request = builder.build(mockRelativePathOptions); - expect(request.url).toBe('https://example.xmatters.com/api/xm/1/people?search=test&limit=10'); - expect(request.method).toBe('GET'); - expect(request.headers?.['Content-Type']).toBe('application/json'); - expect(request.headers?.['Accept']).toBe('application/json'); - expect(request.headers?.['default-header']).toBe('default-value'); - expect(request.retryAttempt).toBe(0); - }); + await t.step('URL Construction', async (t) => { + await t.step('builds request with relative path - verifies correct URL construction', () => { + const { builder } = createRequestBuilderTestSetup(); + const request = builder.build(mockRelativePathOptions); + expect(request.url).toBe('https://example.xmatters.com/api/xm/1/people?search=test&limit=10'); + expect(request.method).toBe('GET'); + expect(request.headers?.['Content-Type']).toBe('application/json'); + expect(request.headers?.['Accept']).toBe('application/json'); + expect(request.headers?.['default-header']).toBe('default-value'); + expect(request.retryAttempt).toBe(0); + }); - await t.step('builds request with external URL - bypasses API version path', () => { - const { builder } = createRequestBuilderTestSetup(); - const request = builder.build(mockExternalUrlOptions); - expect(request.url).toBe('https://api.external-service.com/v2/endpoint?key=value'); - expect(request.method).toBe('POST'); - expect(request.headers?.['Content-Type']).toBe('application/json'); - expect(request.headers?.['Accept']).toBe('application/json'); - expect(request.headers?.['Authorization']).toBe('Bearer token'); - }); + await t.step('builds request with external URL - bypasses API version path', () => { + const { builder } = createRequestBuilderTestSetup(); + const request = builder.build(mockExternalUrlOptions); + expect(request.url).toBe('https://api.external-service.com/v2/endpoint?key=value'); + expect(request.method).toBe('POST'); + expect(request.headers?.['Content-Type']).toBe('application/json'); + expect(request.headers?.['Accept']).toBe('application/json'); + expect(request.headers?.['Authorization']).toBe('Bearer token'); + }); - await t.step('preserves existing query parameters in external URLs', () => { - const { builder } = createRequestBuilderTestSetup(); - const options: RequestBuildOptions = { - fullUrl: 'https://api.external-service.com/search?existing=param&another=value', - query: { additional: 'param', new: 'value' }, - }; - const request = builder.build(options); - const url = new URL(request.url); - expect(url.searchParams.get('existing')).toBe('param'); - expect(url.searchParams.get('another')).toBe('value'); - expect(url.searchParams.get('additional')).toBe('param'); - expect(url.searchParams.get('new')).toBe('value'); - }); + await t.step('preserves existing query parameters in external URLs', () => { + const { builder } = createRequestBuilderTestSetup(); + const options: RequestBuildOptions = { + fullUrl: 'https://api.external-service.com/search?existing=param&another=value', + query: { additional: 'param', new: 'value' }, + }; + const request = builder.build(options); + const url = new URL(request.url); + expect(url.searchParams.get('existing')).toBe('param'); + expect(url.searchParams.get('another')).toBe('value'); + expect(url.searchParams.get('additional')).toBe('param'); + expect(url.searchParams.get('new')).toBe('value'); + }); - await t.step('merges headers correctly - request headers override defaults', () => { - const { builder } = createRequestBuilderTestSetup(); - const request = builder.build(mockCustomHeadersOptions); - expect(request.headers?.['Content-Type']).toBe('application/json'); - expect(request.headers?.['Accept']).toBe('application/json'); - expect(request.headers?.['default-header']).toBe('overridden-value'); // Overridden - expect(request.headers?.['custom-header']).toBe('custom-value'); // Added - expect(request.method).toBe('PUT'); - expect(request.body).toEqual({ name: 'test-group' }); + await t.step('works with custom hostname configuration', () => { + const { builder } = createRequestBuilderTestSetup({ + hostname: 'https://custom.xmatters.com', + }); + const options: RequestBuildOptions = { + path: '/notifications', + }; + const request = builder.build(options); + expect(request.url).toBe('https://custom.xmatters.com/api/xm/1/notifications'); + }); }); - await t.step('defaults method to GET when not specified', () => { - const { builder } = createRequestBuilderTestSetup(); - const options: RequestBuildOptions = { - path: '/users', - }; - const request = builder.build(options); - expect(request.method).toBe('GET'); - expect(request.url).toBe('https://example.xmatters.com/api/xm/1/users'); - }); + await t.step('Header Management', async (t) => { + await t.step('merges headers correctly - request headers override defaults', () => { + const { builder } = createRequestBuilderTestSetup(); + const request = builder.build(mockCustomHeadersOptions); + expect(request.headers?.['Content-Type']).toBe('application/json'); + expect(request.headers?.['Accept']).toBe('application/json'); + expect(request.headers?.['default-header']).toBe('overridden-value'); // Overridden + expect(request.headers?.['custom-header']).toBe('custom-value'); // Added + expect(request.method).toBe('PUT'); + expect(request.body).toEqual({ name: 'test-group' }); + }); - await t.step('handles empty query object', () => { - const { builder } = createRequestBuilderTestSetup(); - const options: RequestBuildOptions = { - path: '/devices', - query: {}, - }; - const request = builder.build(options); - expect(request.url).toBe('https://example.xmatters.com/api/xm/1/devices'); + await t.step('works with empty default headers', () => { + const { builder } = createRequestBuilderTestSetup({ + defaultHeaders: {}, + }); + const options: RequestBuildOptions = { + path: '/sites', + headers: { 'Custom-Header': 'value' }, + }; + const request = builder.build(options); + expect(request.headers).toEqual({ 'Custom-Header': 'value' }); + }); }); - await t.step('filters out null and undefined query parameters', () => { - const { builder } = createRequestBuilderTestSetup(); - const options: RequestBuildOptions = { - path: '/events', - query: { - status: 'active', - priority: null, - assignee: undefined, - limit: 25, - }, - }; - const request = builder.build(options); - const url = new URL(request.url); - expect(url.searchParams.get('status')).toBe('active'); - expect(url.searchParams.get('limit')).toBe('25'); - expect(url.searchParams.has('priority')).toBe(false); - expect(url.searchParams.has('assignee')).toBe(false); - }); + await t.step('Query Parameter Handling', async (t) => { + await t.step('handles empty query object', () => { + const { builder } = createRequestBuilderTestSetup(); + const options: RequestBuildOptions = { + path: '/devices', + query: {}, + }; + const request = builder.build(options); + expect(request.url).toBe('https://example.xmatters.com/api/xm/1/devices'); + }); - await t.step('works with custom hostname configuration', () => { - const { builder } = createRequestBuilderTestSetup({ - hostname: 'https://custom.xmatters.com', + await t.step('filters out null and undefined query parameters', () => { + const { builder } = createRequestBuilderTestSetup(); + const options: RequestBuildOptions = { + path: '/events', + query: { + status: 'active', + priority: null, + assignee: undefined, + limit: 25, + }, + }; + const request = builder.build(options); + const url = new URL(request.url); + expect(url.searchParams.get('status')).toBe('active'); + expect(url.searchParams.get('limit')).toBe('25'); + expect(url.searchParams.has('priority')).toBe(false); + expect(url.searchParams.has('assignee')).toBe(false); }); - const options: RequestBuildOptions = { - path: '/notifications', - }; - const request = builder.build(options); - expect(request.url).toBe('https://custom.xmatters.com/api/xm/1/notifications'); - }); - await t.step('works with empty default headers', () => { - const { builder } = createRequestBuilderTestSetup({ - defaultHeaders: {}, + await t.step('handles array query parameters by joining with commas', () => { + const { builder } = createRequestBuilderTestSetup(); + const options: RequestBuildOptions = { + path: '/groups/123', + query: { + embed: ['supervisors', 'services', 'observers'], + tags: ['urgent', 'critical'], + single: 'value', + }, + }; + const request = builder.build(options); + const url = new URL(request.url); + expect(url.searchParams.get('embed')).toBe('supervisors,services,observers'); + expect(url.searchParams.get('tags')).toBe('urgent,critical'); + expect(url.searchParams.get('single')).toBe('value'); }); - const options: RequestBuildOptions = { - path: '/sites', - headers: { 'Custom-Header': 'value' }, - }; - const request = builder.build(options); - expect(request.headers).toEqual({ 'Custom-Header': 'value' }); - }); - await t.step('preserves retry attempt when provided', () => { - const { builder } = createRequestBuilderTestSetup(); - const options: RequestBuildOptions = { - path: '/shifts', - retryAttempt: 2, - }; - const request = builder.build(options); - expect(request.retryAttempt).toBe(2); - }); + await t.step('handles empty arrays gracefully', () => { + const { builder } = createRequestBuilderTestSetup(); + const options: RequestBuildOptions = { + path: '/groups', + query: { + embed: [], + normal: 'value', + }, + }; + const request = builder.build(options); + const url = new URL(request.url); + expect(url.searchParams.get('embed')).toBe(''); + expect(url.searchParams.get('normal')).toBe('value'); + }); - await t.step('Error handling - throws when path does not start with slash', () => { - const { builder } = createRequestBuilderTestSetup(); - let thrownError: unknown; - try { - builder.build({ path: 'people' }); - } catch (error) { - thrownError = error; - } - expect(thrownError).toBeInstanceOf(XmApiError); - const error = thrownError as XmApiError; - expect(error.message).toBe('Path must start with a forward slash, e.g. "/people"'); + await t.step('handles mixed array types by converting to strings', () => { + const { builder } = createRequestBuilderTestSetup(); + const options: RequestBuildOptions = { + path: '/items', + query: { + ids: [1, 2, 3], + flags: [true, false], + mixed: ['string', 42, true], + }, + }; + const request = builder.build(options); + const url = new URL(request.url); + expect(url.searchParams.get('ids')).toBe('1,2,3'); + expect(url.searchParams.get('flags')).toBe('true,false'); + expect(url.searchParams.get('mixed')).toBe('string,42,true'); + }); }); - await t.step('Error handling - throws when both path and fullUrl are provided', () => { - const { builder } = createRequestBuilderTestSetup(); - let thrownError: unknown; - try { - builder.build({ - path: '/people', - fullUrl: 'https://api.external-service.com/v2/endpoint', - }); - } catch (error) { - thrownError = error; - } - expect(thrownError).toBeInstanceOf(XmApiError); - const error = thrownError as XmApiError; - expect(error.message).toBe( - 'Cannot specify both fullUrl and path. Use fullUrl for external endpoints, path for xMatters API endpoints.', - ); + await t.step('Default Behavior', async (t) => { + await t.step('defaults method to GET when not specified', () => { + const { builder } = createRequestBuilderTestSetup(); + const options: RequestBuildOptions = { + path: '/users', + }; + const request = builder.build(options); + expect(request.method).toBe('GET'); + expect(request.url).toBe('https://example.xmatters.com/api/xm/1/users'); + }); + + await t.step('preserves retry attempt when provided', () => { + const { builder } = createRequestBuilderTestSetup(); + const options: RequestBuildOptions = { + path: '/shifts', + retryAttempt: 2, + }; + const request = builder.build(options); + expect(request.retryAttempt).toBe(2); + }); }); - await t.step('Error handling - throws when neither path nor fullUrl is provided', () => { - const { builder } = createRequestBuilderTestSetup(); - let thrownError: unknown; - try { - builder.build({}); - } catch (error) { - thrownError = error; - } - expect(thrownError).toBeInstanceOf(XmApiError); - const error = thrownError as XmApiError; - expect(error.message).toBe('Either path or fullUrl must be provided'); + await t.step('Error Handling', async (t) => { + await t.step('throws when path does not start with slash', () => { + const { builder } = createRequestBuilderTestSetup(); + let thrownError: unknown; + try { + builder.build({ path: 'people' }); + } catch (error) { + thrownError = error; + } + expect(thrownError).toBeInstanceOf(XmApiError); + const error = thrownError as XmApiError; + expect(error.message).toBe('Path must start with a forward slash, e.g. "/people"'); + }); + + await t.step('throws when both path and fullUrl are provided', () => { + const { builder } = createRequestBuilderTestSetup(); + let thrownError: unknown; + try { + builder.build({ + path: '/people', + fullUrl: 'https://api.external-service.com/v2/endpoint', + }); + } catch (error) { + thrownError = error; + } + expect(thrownError).toBeInstanceOf(XmApiError); + const error = thrownError as XmApiError; + expect(error.message).toBe( + 'Cannot specify both fullUrl and path. Use fullUrl for external endpoints, path for xMatters API endpoints.', + ); + }); + + await t.step('throws when neither path nor fullUrl is provided', () => { + const { builder } = createRequestBuilderTestSetup(); + let thrownError: unknown; + try { + builder.build({}); + } catch (error) { + thrownError = error; + } + expect(thrownError).toBeInstanceOf(XmApiError); + const error = thrownError as XmApiError; + expect(error.message).toBe('Either path or fullUrl must be provided'); + }); }); - await t.step('builds complex request with all options', () => { - const { builder } = createRequestBuilderTestSetup(); - const complexOptions: RequestBuildOptions = { - path: '/forms/abc123/submissions', - method: 'PATCH', - query: { - status: 'pending', - priority: 'high', - assignee: 'user123', - }, - headers: { - 'Authorization': 'Bearer access-token', - 'X-Custom-Header': 'custom-value', - 'Content-Type': 'application/vnd.api+json', // Override default - }, - body: { + await t.step('Integration Tests', async (t) => { + await t.step('builds complex request with all options', () => { + const { builder } = createRequestBuilderTestSetup(); + const complexOptions: RequestBuildOptions = { + path: '/forms/abc123/submissions', + method: 'PATCH', + query: { + status: 'pending', + priority: 'high', + assignee: 'user123', + }, + headers: { + 'Authorization': 'Bearer access-token', + 'X-Custom-Header': 'custom-value', + 'Content-Type': 'application/vnd.api+json', // Override default + }, + body: { + data: { + type: 'form-submission', + attributes: { + status: 'reviewed', + comments: 'Looks good', + }, + }, + }, + retryAttempt: 1, + }; + const request = builder.build(complexOptions); + expect(request.url).toBe( + 'https://example.xmatters.com/api/xm/1/forms/abc123/submissions?status=pending&priority=high&assignee=user123', + ); + expect(request.method).toBe('PATCH'); + expect(request.headers?.['Authorization']).toBe('Bearer access-token'); + expect(request.headers?.['X-Custom-Header']).toBe('custom-value'); + expect(request.headers?.['Content-Type']).toBe('application/vnd.api+json'); + expect(request.headers?.['Accept']).toBe('application/json'); + expect(request.headers?.['default-header']).toBe('default-value'); + expect(request.body).toEqual({ data: { type: 'form-submission', attributes: { @@ -232,110 +313,37 @@ Deno.test('RequestBuilder', async (t) => { comments: 'Looks good', }, }, - }, - retryAttempt: 1, - }; - const request = builder.build(complexOptions); - expect(request.url).toBe( - 'https://example.xmatters.com/api/xm/1/forms/abc123/submissions?status=pending&priority=high&assignee=user123', - ); - expect(request.method).toBe('PATCH'); - expect(request.headers?.['Authorization']).toBe('Bearer access-token'); - expect(request.headers?.['X-Custom-Header']).toBe('custom-value'); - expect(request.headers?.['Content-Type']).toBe('application/vnd.api+json'); - expect(request.headers?.['Accept']).toBe('application/json'); - expect(request.headers?.['default-header']).toBe('default-value'); - expect(request.body).toEqual({ - data: { - type: 'form-submission', - attributes: { - status: 'reviewed', - comments: 'Looks good', - }, - }, + }); + expect(request.retryAttempt).toBe(1); }); - expect(request.retryAttempt).toBe(1); - }); - await t.step('integration - verifies external URL is correctly passed to HTTP client', () => { - // This test ensures that when using fullUrl, the complete external URL - // (not just the path) is properly passed to the underlying HTTP client - const { builder } = createRequestBuilderTestSetup(); - const request = builder.build({ - fullUrl: 'https://api.external-service.com/v2/endpoint', - query: { test: 'param' }, + await t.step('verifies external URL is correctly passed to HTTP client', () => { + // This test ensures that when using fullUrl, the complete external URL + // (not just the path) is properly passed to the underlying HTTP client + const { builder } = createRequestBuilderTestSetup(); + const request = builder.build({ + fullUrl: 'https://api.external-service.com/v2/endpoint', + query: { test: 'param' }, + }); + // Verify the request.url contains the complete external URL with query params + expect(request.url).toBe('https://api.external-service.com/v2/endpoint?test=param'); + // This ensures consumers using fullUrl to bypass xMatters API get the complete external URL + expect(request.url).not.toContain('/api/xm/1'); // Should not contain API version + expect(request.url).toContain('api.external-service.com'); // Should contain external domain }); - // Verify the request.url contains the complete external URL with query params - expect(request.url).toBe('https://api.external-service.com/v2/endpoint?test=param'); - - // This ensures consumers using fullUrl to bypass xMatters API get the complete external URL - expect(request.url).not.toContain('/api/xm/1'); // Should not contain API version - expect(request.url).toContain('api.external-service.com'); // Should contain external domain - }); - - await t.step('integration - verifies API path is correctly built with base URL', () => { - // This test ensures that relative API paths are correctly combined with the base URL - const { builder } = createRequestBuilderTestSetup(); - const request = builder.build({ - path: '/groups', - query: { search: 'test' }, + await t.step('verifies API path is correctly built with base URL', () => { + // This test ensures that relative API paths are correctly combined with the base URL + const { builder } = createRequestBuilderTestSetup(); + const request = builder.build({ + path: '/groups', + query: { search: 'test' }, + }); + // Verify the request.url contains the complete API URL + expect(request.url).toBe('https://example.xmatters.com/api/xm/1/groups?search=test'); + // This ensures regular API calls get the proper xMatters API URL structure + expect(request.url).toContain('/api/xm/1'); // Should contain API version + expect(request.url).toContain('example.xmatters.com'); // Should contain configured hostname }); - - // Verify the request.url contains the complete API URL - expect(request.url).toBe('https://example.xmatters.com/api/xm/1/groups?search=test'); - - // This ensures regular API calls get the proper xMatters API URL structure - expect(request.url).toContain('/api/xm/1'); // Should contain API version - expect(request.url).toContain('example.xmatters.com'); // Should contain configured hostname - }); - - await t.step('handles array query parameters by joining with commas', () => { - const { builder } = createRequestBuilderTestSetup(); - const options: RequestBuildOptions = { - path: '/groups/123', - query: { - embed: ['supervisors', 'services', 'observers'], - tags: ['urgent', 'critical'], - single: 'value', - }, - }; - const request = builder.build(options); - const url = new URL(request.url); - expect(url.searchParams.get('embed')).toBe('supervisors,services,observers'); - expect(url.searchParams.get('tags')).toBe('urgent,critical'); - expect(url.searchParams.get('single')).toBe('value'); - }); - - await t.step('handles empty arrays gracefully', () => { - const { builder } = createRequestBuilderTestSetup(); - const options: RequestBuildOptions = { - path: '/groups', - query: { - embed: [], - normal: 'value', - }, - }; - const request = builder.build(options); - const url = new URL(request.url); - expect(url.searchParams.get('embed')).toBe(''); - expect(url.searchParams.get('normal')).toBe('value'); - }); - - await t.step('handles mixed array types by converting to strings', () => { - const { builder } = createRequestBuilderTestSetup(); - const options: RequestBuildOptions = { - path: '/items', - query: { - ids: [1, 2, 3], - flags: [true, false], - mixed: ['string', 42, true], - }, - }; - const request = builder.build(options); - const url = new URL(request.url); - expect(url.searchParams.get('ids')).toBe('1,2,3'); - expect(url.searchParams.get('flags')).toBe('true,false'); - expect(url.searchParams.get('mixed')).toBe('string,42,true'); }); }); diff --git a/src/core/request-handler.test.ts b/src/core/request-handler.test.ts deleted file mode 100644 index 51e85da..0000000 --- a/src/core/request-handler.test.ts +++ /dev/null @@ -1,752 +0,0 @@ -/** - * @fileoverview Test suite for RequestHandler class - * - * This test file follows the established patterns from the groups endpoint test: - * - Uses expect assertions for consistency across the codebase - * - Implements test setup helpers for creating mock dependencies - * - Uses try/finally blocks to ensure proper cleanup of stubs - * - Creates mock data objects for reusable test responses - * - Follows descriptive test step naming conventions - * - Test await t.step('logs error when token refresh fails', async () => { - const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup({ - accessToken: 'expired-token', - refreshToken: 'invalid-refresh-token', - clientId: 'test-client-id', - responses: [ - mockUnauthorizedResponse, - { status: 400, headers: {}, body: { error: 'invalid_grant' } }, - ], - });cess and error scenarios comprehensively - */ - -import { expect } from 'std/expect/mod.ts'; -import { FakeTime } from 'std/testing/time.ts'; -import { stub } from 'std/testing/mock.ts'; -import { RequestHandler } from './request-handler.ts'; -import type { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; -import type { Logger, XmApiConfig } from './types/internal/config.ts'; -import { XmApiError } from './errors.ts'; - -/** - * Mock HTTP client that can simulate sequential responses for testing retry logic - */ -class MockHttpClient implements HttpClient { - private responses: HttpResponse[] = []; - private callIndex = 0; - public requests: HttpRequest[] = []; - - constructor(responses: HttpResponse[]) { - this.responses = responses; - } - - send(request: HttpRequest): Promise { - this.requests.push(request); - const response = this.responses[this.callIndex] || this.responses[this.responses.length - 1]; - this.callIndex++; - return Promise.resolve(response); - } - - reset() { - this.callIndex = 0; - this.requests = []; - } -} - -/** - * Test helper to create RequestHandler test setup - */ -function createRequestHandlerTestSetup(options: { - hostname?: string; - username?: string; - password?: string; - accessToken?: string; - refreshToken?: string; - clientId?: string; - maxRetries?: number; - responses?: HttpResponse[]; -} = {}) { - const { - hostname = 'https://example.xmatters.com', - username = 'testuser', - password = 'password123', - accessToken, - refreshToken, - clientId, - maxRetries = 3, - responses = [mockSuccessResponse], - } = options; - - // Create silent mock logger - const mockLogger: Logger = { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - }; - - // Create auth options based on provided parameters - const mockHttpClient = new MockHttpClient(responses); - - let mockConfig: XmApiConfig; - if (accessToken && refreshToken && clientId) { - // OAuth configuration - all three are required - mockConfig = { - hostname, - accessToken, - refreshToken, - clientId, - maxRetries, - httpClient: mockHttpClient, - logger: mockLogger, - }; - } else { - // Basic auth configuration - mockConfig = { - hostname, - username, - password, - maxRetries, - httpClient: mockHttpClient, - logger: mockLogger, - }; - } - - const requestHandler = new RequestHandler(mockConfig); - - return { mockHttpClient, requestHandler, mockLogger }; -} - -// Mock response data for tests -const mockSuccessResponse: HttpResponse = { - status: 200, - headers: { 'content-type': 'application/json' }, - body: { success: true }, -}; - -const mockErrorResponse: HttpResponse = { - status: 400, - headers: { 'content-type': 'text/plain' }, - body: 'Invalid request', -}; - -const mockRateLimitResponse: HttpResponse = { - status: 429, - headers: { 'retry-after': '1' }, - body: { message: 'Too many requests' }, -}; - -const mockServerErrorResponse: HttpResponse = { - status: 503, - headers: {}, - body: { message: 'Service unavailable' }, -}; - -const mockUnauthorizedResponse: HttpResponse = { - status: 401, - headers: {}, - body: { message: 'Token expired' }, -}; - -const mockTokenRefreshResponse: HttpResponse = { - status: 200, - headers: { 'content-type': 'application/json' }, - body: { - access_token: 'new-token', - refresh_token: 'new-refresh-token', - expires_in: 3600, - }, -}; - -Deno.test('RequestHandler', async (t) => { - await t.step('handles non-JSON response bodies', async () => { - const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup({ - responses: [mockErrorResponse], - }); - - try { - let thrownError: unknown; - try { - await requestHandler.get({ path: '/test' }); - } catch (error) { - thrownError = error; - } - - expect(thrownError).toBeInstanceOf(XmApiError); - const xmError = thrownError as XmApiError; - expect(xmError.message).toBe('Invalid request'); - expect(mockHttpClient.requests.length).toBe(1); - } finally { - mockHttpClient.reset(); - } - }); - - await t.step('retries on rate limit with Retry-After', async () => { - const fakeTime = new FakeTime(); - try { - const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup({ - responses: [mockRateLimitResponse, mockSuccessResponse], - }); - - // Start the async request but DON'T await it yet - // This begins the async chain but allows us to control timing with FakeTime - const requestPromise = requestHandler.get({ path: '/test' }); - - // Allow the first request to complete and set up the timer - // This advances fake time to let the setTimeout callback fire - await fakeTime.nextAsync(); - // Verify the first request completed and retry was triggered - // At this point: initial request failed → setTimeout set → timeout fired → retry executed - expect(mockHttpClient.requests.length).toBe(2); - expect(mockHttpClient.requests[0].retryAttempt).toBe(0); - expect(mockHttpClient.requests[1].retryAttempt).toBe(1); - // Now advance time to trigger any additional timers (should be none) - await fakeTime.nextAsync(); - - // Finally await the original promise to get the result - // By now all async operations have completed thanks to our time control - const response = await requestPromise; - - expect(response.status).toBe(200); - expect(mockHttpClient.requests.length).toBe(2); - - // Verify first request - const firstRequest = mockHttpClient.requests[0]; - expect(firstRequest.retryAttempt).toBe(0); - - // Verify retry request - const retryRequest = mockHttpClient.requests[1]; - expect(retryRequest.retryAttempt).toBe(1); - } finally { - fakeTime.restore(); - } - }); - - await t.step('retries with exponential backoff on server error', async () => { - const fakeTime = new FakeTime(); - try { - const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup({ - responses: [mockServerErrorResponse, mockSuccessResponse], - }); - - // Start the async request but DON'T await it yet - // This begins the async chain but allows us to control timing with FakeTime - const requestPromise = requestHandler.get({ path: '/test' }); - - // Allow the first request to complete and set up the timer - // This advances fake time to let the setTimeout callback fire - await fakeTime.nextAsync(); - // Verify the first request completed and retry was triggered - // At this point: initial request failed → setTimeout set → timeout fired → retry executed - expect(mockHttpClient.requests.length).toBe(2); - expect(mockHttpClient.requests[0].retryAttempt).toBe(0); - expect(mockHttpClient.requests[1].retryAttempt).toBe(1); - // Now advance time to trigger any additional timers (should be none) - await fakeTime.nextAsync(); - - // Finally await the original promise to get the result - // By now all async operations have completed thanks to our time control - const response = await requestPromise; - - expect(response.status).toBe(200); - expect(mockHttpClient.requests.length).toBe(2); - - // Verify retry attempt increments - const retryRequest = mockHttpClient.requests[1]; - expect(retryRequest.retryAttempt).toBe(1); - } finally { - fakeTime.restore(); - } - }); - - await t.step('stops retrying after max attempts', async () => { - const fakeTime = new FakeTime(); - try { - const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup({ - maxRetries: 3, // Use full retry count to properly test the behavior - responses: [mockServerErrorResponse], // Will repeat the error response - }); - - let thrownError: unknown; - const requestPromise = requestHandler.get({ path: '/test' }).catch((error) => { - thrownError = error; - }); - - // Allow all request attempts and retries to complete - // The pattern is: request -> setTimeout -> retry -> setTimeout -> retry -> etc. - await fakeTime.nextAsync(); // First request completes, setTimeout for retry 1 - await fakeTime.nextAsync(); // Retry 1 completes, setTimeout for retry 2 - await fakeTime.nextAsync(); // Retry 2 completes, setTimeout for retry 3 - await fakeTime.nextAsync(); // Retry 3 completes, should throw error - - await requestPromise; - - expect(thrownError).toBeInstanceOf(XmApiError); - const xmError = thrownError as XmApiError; - expect(xmError.message).toBe('Service unavailable'); - expect(mockHttpClient.requests.length).toBe(4); // Initial + 3 retries (maxRetries=3) - - // Verify each request has the correct retry attempt number - expect(mockHttpClient.requests[0].retryAttempt).toBe(0); - expect(mockHttpClient.requests[1].retryAttempt).toBe(1); - expect(mockHttpClient.requests[2].retryAttempt).toBe(2); - expect(mockHttpClient.requests[3].retryAttempt).toBe(3); - - mockHttpClient.reset(); - } finally { - fakeTime.restore(); - } - }); - - await t.step('handles network errors', async () => { - const { mockHttpClient: _mockHttpClient } = createRequestHandlerTestSetup(); - - // Create a separate mock client that throws network errors - const mockHttpClient: HttpClient = { - send: () => Promise.reject(new Error('Network error')), - }; - - const networkRequestHandler = new RequestHandler({ - hostname: 'https://example.xmatters.com', - username: 'test', - password: 'test', - maxRetries: 3, - httpClient: mockHttpClient, - logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, - }); - - try { - let thrownError: unknown; - try { - await networkRequestHandler.get({ path: '/test' }); - } catch (error) { - thrownError = error; - } - - expect(thrownError).toBeInstanceOf(XmApiError); - const xmError = thrownError as XmApiError; - expect(xmError.message).toBe('Request failed'); - expect(xmError.response).toBeNull(); - expect(xmError.cause).toBeInstanceOf(Error); - expect((xmError.cause as Error).message).toBe('Network error'); - } finally { - // No cleanup needed for this test - } - }); - - await t.step('adds Basic Auth header to requests', async () => { - const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup(); - - try { - await requestHandler.get({ path: '/test' }); - - expect(mockHttpClient.requests.length).toBe(1); - const sentRequest = mockHttpClient.requests[0]; - expect(sentRequest.headers?.Authorization).toBeDefined(); - - // Verify it's Basic auth - const authHeader = sentRequest.headers!.Authorization!; - const [authType] = authHeader.split(' '); - expect(authType).toBe('Basic'); - } finally { - mockHttpClient.reset(); - } - }); - - await t.step('adds OAuth Bearer token to requests', async () => { - const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup({ - accessToken: 'test-access-token', - refreshToken: 'test-refresh-token', - clientId: 'test-client-id', - }); - - try { - await requestHandler.get({ path: '/test' }); - - expect(mockHttpClient.requests.length).toBe(1); - const sentRequest = mockHttpClient.requests[0]; - expect(sentRequest.headers?.Authorization).toBe('Bearer test-access-token'); - } finally { - mockHttpClient.reset(); - } - }); - - await t.step('refreshes token on 401 response', async () => { - const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup({ - accessToken: 'old-token', - refreshToken: 'refresh-token', - clientId: 'test-client-id', - responses: [mockUnauthorizedResponse, mockTokenRefreshResponse, mockSuccessResponse], - }); - - try { - const response = await requestHandler.get({ path: '/test' }); - - expect(response.status).toBe(200); - expect(mockHttpClient.requests.length).toBe(3); - - // Verify token refresh request - const refreshRequest = mockHttpClient.requests[1]; - expect(refreshRequest.url).toBe('https://example.xmatters.com/api/xm/1/oauth2/token'); - expect(refreshRequest.headers?.['Content-Type']).toBe('application/x-www-form-urlencoded'); - expect(refreshRequest.body).toBeDefined(); - - const params = new URLSearchParams(refreshRequest.body as string); - expect(params.get('grant_type')).toBe('refresh_token'); - expect(params.get('refresh_token')).toBe('refresh-token'); - expect(params.get('client_id')).toBe('test-client-id'); - - // Verify retried request uses new token - const retriedRequest = mockHttpClient.requests[2]; - expect(retriedRequest.headers?.Authorization).toBe('Bearer new-token'); - } finally { - mockHttpClient.reset(); - } - }); - - await t.step('skips auth headers when skipAuth is true', async () => { - const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup(); - - try { - await requestHandler.send({ path: '/oauth2/token', skipAuth: true }); - - expect(mockHttpClient.requests.length).toBe(1); - const sentRequest = mockHttpClient.requests[0]; - expect(sentRequest.headers?.Authorization).toBeUndefined(); - } finally { - mockHttpClient.reset(); - } - }); - - await t.step('logs debug message when retrying requests', async () => { - const fakeTime = new FakeTime(); - try { - const { mockHttpClient, requestHandler, mockLogger } = createRequestHandlerTestSetup({ - responses: [mockRateLimitResponse, mockSuccessResponse], - }); - - // Stub the debug method to capture calls - const debugStub = stub(mockLogger, 'debug', () => {}); - - try { - // Start the async request but DON'T await it yet - // This begins the async chain but allows us to control timing with FakeTime - const requestPromise = requestHandler.get({ path: '/test' }); - - // Allow the first request to complete and set up the timer - // This advances fake time to let the setTimeout callback fire - await fakeTime.nextAsync(); - // Verify the first request completed and retry was triggered - // At this point: initial request failed → setTimeout set → timeout fired → retry executed - expect(mockHttpClient.requests.length).toBe(2); - expect(mockHttpClient.requests[0].retryAttempt).toBe(0); - expect(mockHttpClient.requests[1].retryAttempt).toBe(1); - // Now advance time to trigger any additional timers (should be none) - await fakeTime.nextAsync(); - - // Finally await the original promise to get the response - // By now all async operations have completed thanks to our time control - const response = await requestPromise; - - expect(response.status).toBe(200); - expect(mockHttpClient.requests.length).toBe(2); - - // Verify debug logger was called with correct retry message - // Should be: - // initial request --> - // + initial request <-- - // + retry message - // + retry request --> - // + retry request <-- - // = 5 calls - expect(debugStub.calls.length).toBe(5); - expect(debugStub.calls[2].args[0]).toBe( - 'Request failed with status 429, retrying in 1000ms (attempt 1/3)', - ); - } finally { - debugStub.restore(); - } - } finally { - fakeTime.restore(); - } - }); - - await t.step('logs error when token refresh fails', async () => { - const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup({ - accessToken: 'expired-token', - refreshToken: 'invalid-refresh-token', - clientId: 'test-client-id', - responses: [ - mockUnauthorizedResponse, - { status: 400, headers: {}, body: { error: 'invalid_grant' } }, - ], - }); - - try { - let thrownError: unknown; - try { - await requestHandler.get({ path: '/test' }); - } catch (error) { - thrownError = error; - } - - expect(thrownError).toBeInstanceOf(XmApiError); - expect(mockHttpClient.requests.length).toBe(2); // Initial 401 + failed token refresh - - // Verify error details are correct - const xmError = thrownError as XmApiError; - expect(xmError.message).toBe('Failed to refresh token'); - expect(xmError.response?.status).toBe(400); - } finally { - mockHttpClient.reset(); - } - }); - - await t.step('logs warning when onTokenRefresh callback throws error', async () => { - const throwingCallback = () => { - throw new Error('Callback error'); - }; - - const { mockHttpClient, mockLogger } = createRequestHandlerTestSetup({ - accessToken: 'expired-token', - refreshToken: 'valid-refresh-token', - clientId: 'test-client-id', - responses: [mockUnauthorizedResponse, mockTokenRefreshResponse, mockSuccessResponse], - }); - - // Override the options to include the throwing callback - const requestHandlerWithCallback = new RequestHandler({ - hostname: 'https://example.xmatters.com', - accessToken: 'expired-token', - refreshToken: 'valid-refresh-token', - clientId: 'test-client-id', - maxRetries: 3, - httpClient: mockHttpClient, - logger: mockLogger, - onTokenRefresh: throwingCallback, - }); - - // Stub the warn method to capture calls - const warnStub = stub(mockLogger, 'warn', () => {}); - - try { - const response = await requestHandlerWithCallback.get({ path: '/test' }); - - expect(response.status).toBe(200); - expect(mockHttpClient.requests.length).toBe(3); // Initial 401 + token refresh + retry - - // Verify warning logger was called - expect(warnStub.calls.length).toBe(1); - expect(warnStub.calls[0].args[0]).toBe( - 'Error in onTokenRefresh callback, but continuing with refreshed token', - ); - expect(warnStub.calls[0].args[1]).toBeInstanceOf(Error); - expect((warnStub.calls[0].args[1] as Error).message).toBe('Callback error'); - } finally { - warnStub.restore(); - mockHttpClient.reset(); - } - }); - - await t.step('throws error when token refresh returns non-200 status', async () => { - const { mockHttpClient, requestHandler } = createRequestHandlerTestSetup({ - accessToken: 'expired-token', - refreshToken: 'invalid-refresh-token', - clientId: 'test-client-id', - responses: [ - mockUnauthorizedResponse, - { status: 401, headers: {}, body: { error: 'invalid_client' } }, - ], - }); - - try { - let thrownError: unknown; - try { - await requestHandler.get({ path: '/test' }); - } catch (error) { - thrownError = error; - } - - expect(thrownError).toBeInstanceOf(XmApiError); - const xmError = thrownError as XmApiError; - expect(xmError.message).toBe('Failed to refresh token'); - expect(xmError.response?.status).toBe(401); - } finally { - mockHttpClient.reset(); - } - }); - - await t.step('logs debug message with exponential backoff delay on server errors', async () => { - const fakeTime = new FakeTime(); - try { - const { mockHttpClient, requestHandler, mockLogger } = createRequestHandlerTestSetup({ - responses: [mockServerErrorResponse, mockSuccessResponse], - }); - - // Stub the debug method to capture calls - const debugStub = stub(mockLogger, 'debug', () => {}); - - try { - // Start the async request but DON'T await it yet - // This begins the async chain but allows us to control timing with FakeTime - const requestPromise = requestHandler.get({ path: '/test' }); - - // Allow the first request to complete and set up the timer - // This advances fake time to let the setTimeout callback fire - await fakeTime.nextAsync(); - // Verify the first request completed and retry was triggered - // At this point: initial request failed → setTimeout set → timeout fired → retry executed - expect(mockHttpClient.requests.length).toBe(2); - expect(mockHttpClient.requests[0].retryAttempt).toBe(0); - expect(mockHttpClient.requests[1].retryAttempt).toBe(1); - // Now advance time to trigger any additional timers (should be none) - await fakeTime.nextAsync(); - - // Finally await the original promise to get the result - // By now all async operations have completed thanks to our time control - const response = await requestPromise; - - expect(response.status).toBe(200); - expect(mockHttpClient.requests.length).toBe(2); - - // Verify debug logger was called with exponential backoff message - // Should be: - // initial request --> - // + initial request <-- - // + retry message - // + retry request --> - // + retry request <-- - // = 5 calls - expect(debugStub.calls.length).toBe(5); - expect(debugStub.calls[2].args[0]).toBe( - 'Request failed with status 503, retrying in 1000ms (attempt 1/3)', - ); - } finally { - debugStub.restore(); - } - } finally { - fakeTime.restore(); - } - }); - - await t.step('respects Retry-After header and logs correct delay', async () => { - const fakeTime = new FakeTime(); - try { - const customRateLimitResponse: HttpResponse = { - status: 429, - headers: { 'retry-after': '5' }, // 5 seconds - body: { message: 'Too many requests' }, - }; - - const { mockHttpClient, requestHandler, mockLogger } = createRequestHandlerTestSetup({ - responses: [customRateLimitResponse, mockSuccessResponse], - }); - - // Stub the debug method to capture calls - const debugStub = stub(mockLogger, 'debug', () => {}); - - try { - const requestPromise = requestHandler.get({ path: '/test' }); - - // Allow the first request to complete and set up the timer - await fakeTime.nextAsync(); - // Verify the first request completed and retry was triggered - expect(mockHttpClient.requests.length).toBe(2); - expect(mockHttpClient.requests[0].retryAttempt).toBe(0); - expect(mockHttpClient.requests[1].retryAttempt).toBe(1); - // Now advance time to trigger any additional timers (should be none) - await fakeTime.nextAsync(); - - const response = await requestPromise; - - expect(response.status).toBe(200); - expect(mockHttpClient.requests.length).toBe(2); - - // Verify debug logger was called with Retry-After header value - // Should be: - // initial request --> - // + initial request <-- - // + retry message - // + retry request --> - // + retry request <-- - // = 5 calls - expect(debugStub.calls.length).toBe(5); - expect(debugStub.calls[2].args[0]).toBe( - 'Request failed with status 429, retrying in 5000ms (attempt 1/3)', - ); - } finally { - debugStub.restore(); - } - } finally { - fakeTime.restore(); - } - }); - - await t.step( - 'integration - verifies external URL is passed correctly to HTTP client', - async () => { - // This test ensures that when using fullUrl, the external URL is properly passed - // to the HTTP client, not the xMatters API URL - const mockHttpClient = new MockHttpClient([mockSuccessResponse]); - - const requestHandler = new RequestHandler({ - hostname: 'https://company.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, - }); - - try { - await requestHandler.send({ - fullUrl: 'https://api.external-service.com/v2/endpoint', - query: { test: 'param' }, - method: 'GET', - }); - - // Verify that the HTTP client received the correct external URL - expect(mockHttpClient.requests.length).toBe(1); - const sentRequest = mockHttpClient.requests[0]; - - // The key assertion: HTTP client should receive the external URL, not the xMatters API URL - expect(sentRequest.url).toBe('https://api.external-service.com/v2/endpoint?test=param'); - expect(sentRequest.url).not.toContain('company.xmatters.com'); // Should not contain xMatters hostname - expect(sentRequest.url).not.toContain('/api/xm/1'); // Should not contain API version - } finally { - mockHttpClient.reset(); - } - }, - ); - - await t.step('integration - verifies API path is passed correctly to HTTP client', async () => { - // This test ensures that relative API paths result in correct xMatters API URLs - // being passed to the HTTP client - const mockHttpClient = new MockHttpClient([mockSuccessResponse]); - - const requestHandler = new RequestHandler({ - hostname: 'https://company.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, - }); - - try { - await requestHandler.send({ - path: '/groups', - query: { search: 'test' }, - method: 'GET', - }); - - // Verify that the HTTP client received the correct xMatters API URL - expect(mockHttpClient.requests.length).toBe(1); - const sentRequest = mockHttpClient.requests[0]; - - // The key assertion: HTTP client should receive the full xMatters API URL - expect(sentRequest.url).toBe('https://company.xmatters.com/api/xm/1/groups?search=test'); - expect(sentRequest.url).toContain('company.xmatters.com'); // Should contain xMatters hostname - expect(sentRequest.url).toContain('/api/xm/1'); // Should contain API version - } finally { - mockHttpClient.reset(); - } - }); -}); diff --git a/src/core/resource-client.test.ts b/src/core/resource-client.test.ts index fbfea5a..a2cb735 100644 --- a/src/core/resource-client.test.ts +++ b/src/core/resource-client.test.ts @@ -2,38 +2,12 @@ import { expect } from 'std/expect/mod.ts'; import { ResourceClient } from './resource-client.ts'; import { RequestHandler } from './request-handler.ts'; import { XmApiError } from './errors.ts'; -import type { HttpRequest, HttpResponse } from './types/internal/http.ts'; - -// Mock HTTP client for testing -class MockHttpClient { - private responses: HttpResponse[] = []; - private requestHistory: HttpRequest[] = []; - addResponse(response: HttpResponse) { - this.responses.push(response); - } - getRequestHistory(): HttpRequest[] { - return this.requestHistory; - } - send(request: HttpRequest): Promise { - this.requestHistory.push(request); - if (this.responses.length === 0) { - throw new Error('MockHttpClient: No more responses configured'); - } - return Promise.resolve(this.responses.shift()!); - } -} - -// Create silent mock logger -const mockLogger = { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, -}; +import { MockHttpClient, MockLogger } from './test-utils.ts'; // Helper to create ResourceClient with mock dependencies function createResourceClientTestSetup(basePath: string) { const mockHttpClient = new MockHttpClient(); + const mockLogger = new MockLogger(); const requestHandler = new RequestHandler({ httpClient: mockHttpClient, logger: mockLogger, @@ -75,148 +49,268 @@ Deno.test('ResourceClient', async (t) => { const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); const client = createResourceClient(); // Mock successful response - mockHttpClient.addResponse({ - status: 200, - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ success: true }), - }); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups/members', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { success: true }, + }, + }]); await client.get({ path: 'members' }); - const requests = mockHttpClient.getRequestHistory(); - expect(requests).toHaveLength(1); - expect(requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/members'); + expect(mockHttpClient.requests).toHaveLength(1); + expect(mockHttpClient.requests[0].url).toBe( + 'https://test.xmatters.com/api/xm/1/groups/members', + ); + mockHttpClient.verifyAllRequestsMade(); }); await t.step('get() - uses base path when no path provided', async () => { const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); const client = createResourceClient(); - mockHttpClient.addResponse({ - status: 200, - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ success: true }), - }); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { success: true }, + }, + }]); await client.get({}); - const requests = mockHttpClient.getRequestHistory(); - expect(requests).toHaveLength(1); - expect(requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups'); + expect(mockHttpClient.requests).toHaveLength(1); + expect(mockHttpClient.requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups'); + mockHttpClient.verifyAllRequestsMade(); }); await t.step('get() - strips leading slash from provided path', async () => { const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); const client = createResourceClient(); - mockHttpClient.addResponse({ - status: 200, - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ success: true }), - }); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups/members', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { success: true }, + }, + }]); await client.get({ path: '/members' }); - const requests = mockHttpClient.getRequestHistory(); - expect(requests).toHaveLength(1); - expect(requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/members'); + expect(mockHttpClient.requests).toHaveLength(1); + expect(mockHttpClient.requests[0].url).toBe( + 'https://test.xmatters.com/api/xm/1/groups/members', + ); + mockHttpClient.verifyAllRequestsMade(); }); await t.step('post() - prepends base path correctly', async () => { const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); const client = createResourceClient(); - mockHttpClient.addResponse({ - status: 201, - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ id: '123' }), - }); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/groups/new-group', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + body: { name: 'Test Group' }, + }, + mockedResponse: { + status: 201, + headers: { 'content-type': 'application/json' }, + body: { id: '123' }, + }, + }]); await client.post({ path: 'new-group', body: { name: 'Test Group' }, }); - const requests = mockHttpClient.getRequestHistory(); - expect(requests).toHaveLength(1); - expect(requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/new-group'); - expect(requests[0].method).toBe('POST'); + expect(mockHttpClient.requests).toHaveLength(1); + expect(mockHttpClient.requests[0].url).toBe( + 'https://test.xmatters.com/api/xm/1/groups/new-group', + ); + expect(mockHttpClient.requests[0].method).toBe('POST'); + mockHttpClient.verifyAllRequestsMade(); }); await t.step('put() - prepends base path correctly', async () => { const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); const client = createResourceClient(); - mockHttpClient.addResponse({ - status: 200, - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ id: '123' }), - }); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'PUT', + url: 'https://test.xmatters.com/api/xm/1/groups/123', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + body: { name: 'Updated Group' }, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { id: '123' }, + }, + }]); await client.put({ path: '123', body: { name: 'Updated Group' }, }); - const requests = mockHttpClient.getRequestHistory(); - expect(requests).toHaveLength(1); - expect(requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/123'); - expect(requests[0].method).toBe('PUT'); + expect(mockHttpClient.requests).toHaveLength(1); + expect(mockHttpClient.requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/123'); + expect(mockHttpClient.requests[0].method).toBe('PUT'); + mockHttpClient.verifyAllRequestsMade(); }); await t.step('patch() - prepends base path correctly', async () => { const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); const client = createResourceClient(); - mockHttpClient.addResponse({ - status: 200, - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ id: '123' }), - }); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'PATCH', + url: 'https://test.xmatters.com/api/xm/1/groups/123', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + body: { name: 'Patched Group' }, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { id: '123' }, + }, + }]); await client.patch({ path: '123', body: { name: 'Patched Group' }, }); - const requests = mockHttpClient.getRequestHistory(); - expect(requests).toHaveLength(1); - expect(requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/123'); - expect(requests[0].method).toBe('PATCH'); + expect(mockHttpClient.requests).toHaveLength(1); + expect(mockHttpClient.requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/123'); + expect(mockHttpClient.requests[0].method).toBe('PATCH'); + mockHttpClient.verifyAllRequestsMade(); }); await t.step('delete() - prepends base path correctly', async () => { const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); const client = createResourceClient(); - mockHttpClient.addResponse({ - status: 204, - headers: {}, - body: '', - }); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'DELETE', + url: 'https://test.xmatters.com/api/xm/1/groups/123', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 204, + headers: {}, + body: '', + }, + }]); await client.delete({ path: '123' }); - const requests = mockHttpClient.getRequestHistory(); - expect(requests).toHaveLength(1); - expect(requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/123'); - expect(requests[0].method).toBe('DELETE'); + expect(mockHttpClient.requests).toHaveLength(1); + expect(mockHttpClient.requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/123'); + expect(mockHttpClient.requests[0].method).toBe('DELETE'); + mockHttpClient.verifyAllRequestsMade(); }); await t.step('Complex path building - handles nested paths correctly', async () => { const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); const client = createResourceClient(); - mockHttpClient.addResponse({ - status: 200, - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ success: true }), - }); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups/123/members/456', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { success: true }, + }, + }]); await client.get({ path: '123/members/456' }); - const requests = mockHttpClient.getRequestHistory(); - expect(requests).toHaveLength(1); - expect(requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/123/members/456'); + expect(mockHttpClient.requests).toHaveLength(1); + expect(mockHttpClient.requests[0].url).toBe( + 'https://test.xmatters.com/api/xm/1/groups/123/members/456', + ); + mockHttpClient.verifyAllRequestsMade(); }); await t.step('Passes through all other options unchanged', async () => { const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); const client = createResourceClient(); - mockHttpClient.addResponse({ - status: 200, - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ success: true }), - }); const testHeaders = { 'Custom-Header': 'test-value' }; const testQuery = { page: '1', limit: '10' }; + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups/members?page=1&limit=10', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + 'Custom-Header': 'test-value', + }, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { success: true }, + }, + }]); await client.get({ path: 'members', headers: testHeaders, query: testQuery, }); - const requests = mockHttpClient.getRequestHistory(); - expect(requests).toHaveLength(1); + expect(mockHttpClient.requests).toHaveLength(1); // Check that custom headers are included - expect(requests[0].headers?.['Custom-Header']).toBe('test-value'); - expect(requests[0].url).toBe( + expect(mockHttpClient.requests[0].headers?.['Custom-Header']).toBe('test-value'); + expect(mockHttpClient.requests[0].url).toBe( 'https://test.xmatters.com/api/xm/1/groups/members?page=1&limit=10', ); + mockHttpClient.verifyAllRequestsMade(); }); }); diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts index e8ad7a4..29a21af 100644 --- a/src/core/test-utils.ts +++ b/src/core/test-utils.ts @@ -91,7 +91,7 @@ export class MockHttpClient implements HttpClient { ): void { expect(actualRequest.method).toBe(expectedRequest.method); expect(actualRequest.url).toBe(expectedRequest.url); - expect(actualRequest.body).toBe(expectedRequest.body); + expect(actualRequest.body).toEqual(expectedRequest.body); expect(actualRequest.headers).toEqual(expectedRequest.headers); } } diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index a51b5f5..fdbe734 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -658,9 +658,9 @@ Deno.test('GroupsEndpoint', async (t) => { }; const tokenRefreshErrorResponse = { - status: 400, + status: 401, headers: { 'content-type': 'application/json' }, - body: { error: 'invalid_grant', error_description: 'Invalid refresh token' }, + body: { code: 401, message: 'Invalid refresh token', reason: 'Unauthorized' }, }; let callCount = 0; @@ -687,7 +687,7 @@ Deno.test('GroupsEndpoint', async (t) => { // Verify error details are correct const xmError = thrownError as XmApiError; expect(xmError.message).toBe('Failed to refresh token'); - expect(xmError.response?.status).toBe(400); + expect(xmError.response?.status).toBe(401); } finally { sendStub.restore(); } diff --git a/src/index.test.ts b/src/index.test.ts index 0d7c4ef..9f18c3a 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -745,58 +745,18 @@ Deno.test('XmApi', async (t) => { mockLogger.verifyAllLogsLogged(); }); - /* - - 1. Authentication Integration: - + Basic Auth with proper header encoding - + OAuth Bearer token authentication - + Token refresh on 401 responses - + Token refresh callback handling with error safety - - 2. HTTP Client Integration: - + Request building and sending through injected HTTP client - + Custom headers merging (default + per-request) - + User-Agent header generation from deno.json version - - 3. Retry Logic: - + 429 rate limit retries with Retry-After header respect - + 500 server error retries with exponential backoff - + Maximum retry attempts enforcement - + Proper error handling after max retries exceeded - - 4. Logging Integration: - + Request/response logging through injected logger - + Debug logging for retry attempts - + Warning logging for token refresh callback errors - - 5. Error Handling: - + Proper XmApiError instances with response details - + Network error handling with cause preservation - + Consistent error structure for consumers - - 6. OAuth Token Management: - + Token acquisition from basic auth credentials - + Token refresh callback execution - + Error handling in token refresh callbacks - - */ - - /* - - === DEMO TESTS FOR MAINTAINERS === - These tests showcase the MockLogger pattern matching capabilities, particularly for handling time-dependent logs. - - */ - - Deno.test('DEMO: MockLogger Pattern Matching - String vs RegExp', async () => { - const mockHttpClient = new MockHttpClient(); - const mockLogger = new MockLogger(); - - // Set up a request that will generate timing logs + await t.step('Non-JSON Response Body Handling', async () => { + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + }); mockHttpClient.setReqRes([{ expectedRequest: { method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', + url: 'https://test.xmatters.com/api/xm/1/groups/invalid', headers: { 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', 'Content-Type': 'application/json', @@ -805,97 +765,137 @@ Deno.test('XmApi', async (t) => { }, }, mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: { groups: [] }, + status: 400, + headers: { 'Content-Type': 'text/plain' }, + body: 'Invalid request format', }, }]); + try { + await api.groups.getByIdentifier('invalid'); + } catch (error) { + const apiError = error as XmApiError; + expect(apiError).toBeInstanceOf(XmApiError); + expect(apiError.message).toBe('Invalid request format'); + expect(apiError.response?.status).toBe(400); + expect(apiError.response?.body).toBe('Invalid request format'); + } finally { + mockHttpClient.verifyAllRequestsMade(); + } + }); - // Demo: Mix of exact string matching and pattern matching - mockLogger.setExpectedLogs([ - { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, // Exact string match - { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, // Pattern match for timing - ]); - - const config = { + await t.step('Token Refresh Failure Scenarios', async () => { + const api = new XmApi({ hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', + accessToken: 'expired-token', + refreshToken: 'invalid-refresh-token', + clientId: 'test-client-id', httpClient: mockHttpClient, logger: mockLogger, - }; - - const api = new XmApi(config); - await api.groups.get(); - - // Verify both types of log matching worked - mockLogger.verifyAllLogsLogged(); - mockHttpClient.verifyAllRequestsMade(); - }); - - Deno.test('DEMO: MockLogger Pattern Matching - Complex Timing Patterns', async () => { - const mockHttpClient = new MockHttpClient(); - const mockLogger = new MockLogger(); - - // Set up multiple requests to show various timing patterns + }); mockHttpClient.setReqRes([ + // First request fails with 401 { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Authorization': 'Bearer expired-token', 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, mockedResponse: { - status: 200, + status: 401, headers: { 'Content-Type': 'application/json' }, - body: { groups: [] }, + body: { error: 'Token expired' }, }, }, + // Token refresh request fails { expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups/test-group', + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', }, + body: + 'grant_type=refresh_token&refresh_token=invalid-refresh-token&client_id=test-client-id', }, mockedResponse: { - status: 200, + status: 401, headers: { 'Content-Type': 'application/json' }, - body: { id: 'test-group', name: 'Test Group' }, + // Real error structure from xMatters API (verified via sandbox testing) + body: { code: 401, message: 'Invalid refresh token', reason: 'Unauthorized' }, }, }, ]); + try { + await api.groups.get(); + } catch (error) { + const apiError = error as XmApiError; + expect(apiError).toBeInstanceOf(XmApiError); + expect(apiError.message).toBe('Failed to refresh token'); + expect(apiError.response?.status).toBe(401); + expect(apiError.response?.body).toEqual({ + code: 401, + message: 'Invalid refresh token', + reason: 'Unauthorized', + }); + } finally { + mockHttpClient.verifyAllRequestsMade(); + } + }); - // Demo: Various pattern matching scenarios - mockLogger.setExpectedLogs([ - { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, - { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, // Any positive duration - { level: 'debug', message: /^--> GET .*groups\/test-group/ }, // Pattern for method + partial URL - { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, // Another timing pattern - ]); + /* - const config = { - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, - }; + === INTEGRATION TEST COVERAGE SUMMARY === - const api = new XmApi(config); - await api.groups.get(); - await api.groups.getByIdentifier('test-group'); + Now covers all scenarios from request-handler.test.ts: - mockLogger.verifyAllLogsLogged(); - mockHttpClient.verifyAllRequestsMade(); - }); + 1. Authentication Integration: + ✓ Basic Auth with proper header encoding + ✓ OAuth Bearer token authentication + ✓ Token refresh on 401 responses + ✓ Token refresh callback handling with error safety + ✓ Token refresh failure scenarios (NEW) + ✓ skipAuth behavior (implicit in OAuth token acquisition - no auth headers) + + 2. HTTP Client Integration: + ✓ Request building and sending through injected HTTP client + ✓ Custom headers merging (default + per-request) + ✓ User-Agent header generation from deno.json version + ✓ External URL support (conceptual - for future implementation) + ✓ URL construction verification (implicit in every test via mock validation) + + 3. Retry Logic: + ✓ 429 rate limit retries with Retry-After header respect (includes detailed delay logging) + ✓ 500 server error retries with exponential backoff + ✓ Maximum retry attempts enforcement + ✓ Proper error handling after max retries exceeded + + 4. Response Handling: + ✓ JSON response parsing + ✓ Non-JSON response body handling (NEW) + ✓ Proper XmApiError instances with response details + ✓ Network error handling with cause preservation + + 5. Logging Integration: + ✓ Request/response logging through injected logger + ✓ Debug logging for retry attempts with detailed timing + ✓ Warning logging for token refresh callback errors + + 6. OAuth Token Management: + ✓ Token acquisition from basic auth credentials (inherently tests skipAuth) + ✓ Token refresh callback execution + ✓ Error handling in token refresh callbacks + ✓ Token refresh failure error handling (NEW) + + Note: URL construction, detailed retry timing, and skipAuth behavior are thoroughly + tested implicitly across all test cases via mock validation and OAuth endpoint + testing, eliminating the need for dedicated tests for these scenarios. + + */ }); From a0e1bfffa72c8e744be206b9331ba84b3a3a9aa0 Mon Sep 17 00:00:00 2001 From: johan Date: Thu, 26 Jun 2025 09:03:44 -0700 Subject: [PATCH 072/101] Continue refactoring unit tests --- src/core/utils/config-validation.test.ts | 864 ++++++++++++----------- 1 file changed, 463 insertions(+), 401 deletions(-) diff --git a/src/core/utils/config-validation.test.ts b/src/core/utils/config-validation.test.ts index 04074a7..a9158cf 100644 --- a/src/core/utils/config-validation.test.ts +++ b/src/core/utils/config-validation.test.ts @@ -2,426 +2,488 @@ import { expect } from 'std/expect/mod.ts'; import { validateConfig } from './config-validation.ts'; import { XmApiError } from '../errors.ts'; -Deno.test('validateConfig - null/undefined config', () => { - // @ts-ignore - Testing invalid input types - expect(() => validateConfig(null)).toThrow(XmApiError); - // @ts-ignore - Testing invalid input types - expect(() => validateConfig(null)).toThrow('Configuration object is required'); - // @ts-ignore - Testing invalid input types - expect(() => validateConfig(undefined)).toThrow(XmApiError); - // @ts-ignore - Testing invalid input types - expect(() => validateConfig(undefined)).toThrow('Configuration object is required'); -}); +Deno.test('validateConfig', async (t) => { + await t.step('Input Validation', async (t) => { + await t.step('rejects null/undefined config', () => { + // @ts-ignore - Testing invalid input types + expect(() => validateConfig(null)).toThrow(XmApiError); + // @ts-ignore - Testing invalid input types + expect(() => validateConfig(null)).toThrow('Configuration object is required'); + // @ts-ignore - Testing invalid input types + expect(() => validateConfig(undefined)).toThrow(XmApiError); + // @ts-ignore - Testing invalid input types + expect(() => validateConfig(undefined)).toThrow('Configuration object is required'); + }); -Deno.test('validateConfig - non-object config', () => { - // @ts-ignore - Testing invalid input types - expect(() => validateConfig('string')).toThrow(XmApiError); - // @ts-ignore - Testing invalid input types - expect(() => validateConfig('string')).toThrow('Expected object'); - // @ts-ignore - Testing invalid input types - expect(() => validateConfig(123)).toThrow(XmApiError); - // @ts-ignore - Testing invalid input types - expect(() => validateConfig(123)).toThrow('Expected object'); - // @ts-ignore - Testing invalid input types - expect(() => validateConfig(true)).toThrow(XmApiError); - // @ts-ignore - Testing invalid input types - expect(() => validateConfig(true)).toThrow('Expected object'); -}); + await t.step('rejects non-object config', () => { + // @ts-ignore - Testing invalid input types + expect(() => validateConfig('string')).toThrow(XmApiError); + // @ts-ignore - Testing invalid input types + expect(() => validateConfig('string')).toThrow('Expected object'); + // @ts-ignore - Testing invalid input types + expect(() => validateConfig(123)).toThrow(XmApiError); + // @ts-ignore - Testing invalid input types + expect(() => validateConfig(123)).toThrow('Expected object'); + // @ts-ignore - Testing invalid input types + expect(() => validateConfig(true)).toThrow(XmApiError); + // @ts-ignore - Testing invalid input types + expect(() => validateConfig(true)).toThrow('Expected object'); + }); -Deno.test('validateConfig - array config', () => { - // @ts-ignore - Testing invalid input types - expect(() => validateConfig([])).toThrow(XmApiError); - // @ts-ignore - Testing invalid input types - expect(() => validateConfig([])).toThrow('Expected object'); - // @ts-ignore - Testing invalid input types - expect(() => validateConfig(['test'])).toThrow(XmApiError); - // @ts-ignore - Testing invalid input types - expect(() => validateConfig(['test'])).toThrow('Expected object'); -}); + await t.step('rejects array config', () => { + // @ts-ignore - Testing invalid input types + expect(() => validateConfig([])).toThrow(XmApiError); + // @ts-ignore - Testing invalid input types + expect(() => validateConfig([])).toThrow('Expected object'); + // @ts-ignore - Testing invalid input types + expect(() => validateConfig(['test'])).toThrow(XmApiError); + // @ts-ignore - Testing invalid input types + expect(() => validateConfig(['test'])).toThrow('Expected object'); + }); + }); -Deno.test('validateConfig - invalid hostname', () => { - // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ hostname: 123 })).toThrow(XmApiError); - // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ hostname: 123 })).toThrow( - 'Invalid config: hostname must be a valid xMatters hostname (*.xmatters.com or *.xmatters.com.au)', - ); - // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ hostname: null })).toThrow(XmApiError); - // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ hostname: null })).toThrow( - 'Invalid config: hostname must be a valid xMatters hostname (*.xmatters.com or *.xmatters.com.au)', - ); - // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ hostname: undefined })).toThrow(XmApiError); - // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ hostname: undefined })).toThrow( - 'Invalid config: hostname must be a valid xMatters hostname (*.xmatters.com or *.xmatters.com.au)', - ); -}); + await t.step('Hostname Validation', async (t) => { + await t.step('rejects invalid hostname types', () => { + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ hostname: 123 })).toThrow(XmApiError); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ hostname: 123 })).toThrow( + 'Invalid config: hostname must be a valid xMatters hostname (*.xmatters.com or *.xmatters.com.au)', + ); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ hostname: null })).toThrow(XmApiError); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ hostname: null })).toThrow( + 'Invalid config: hostname must be a valid xMatters hostname (*.xmatters.com or *.xmatters.com.au)', + ); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ hostname: undefined })).toThrow(XmApiError); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ hostname: undefined })).toThrow( + 'Invalid config: hostname must be a valid xMatters hostname (*.xmatters.com or *.xmatters.com.au)', + ); + }); -Deno.test('validateConfig - empty hostname', () => { - // @ts-ignore - Testing invalid configuration - expect(() => validateConfig({ hostname: '' })).toThrow(XmApiError); - // @ts-ignore - Testing invalid configuration - expect(() => validateConfig({ hostname: '' })).toThrow( - 'Invalid config: hostname must be a valid xMatters hostname (*.xmatters.com or *.xmatters.com.au)', - ); -}); + await t.step('rejects empty hostname', () => { + // @ts-ignore - Testing invalid configuration + expect(() => validateConfig({ hostname: '' })).toThrow(XmApiError); + // @ts-ignore - Testing invalid configuration + expect(() => validateConfig({ hostname: '' })).toThrow( + 'Invalid config: hostname must be a valid xMatters hostname (*.xmatters.com or *.xmatters.com.au)', + ); + }); -Deno.test('validateConfig - invalid xMatters hostname', () => { - const invalidHostnames = [ - 'google.com', - 'example.org', - 'xmatters.com', // Missing subdomain - 'test.xmatters.co', // Wrong TLD - 'test.xmatters.net', - 'test.xmatters.com.uk', // Wrong country code - 'xmatters.com.au', // Missing subdomain - 'sub.domain.example.com', - 'localhost', - '192.168.1.1', - 'test.xmatters.comm', // Typo in domain - 'testxmatters.com', // Missing dot before xmatters - ]; + await t.step('rejects invalid xMatters hostnames', () => { + const invalidHostnames = [ + 'google.com', + 'example.org', + 'xmatters.com', // Missing subdomain + 'test.xmatters.co', // Wrong TLD + 'test.xmatters.net', + 'test.xmatters.com.uk', // Wrong country code + 'xmatters.com.au', // Missing subdomain + 'sub.domain.example.com', + 'localhost', + '192.168.1.1', + 'test.xmatters.comm', // Typo in domain + 'testxmatters.com', // Missing dot before xmatters + ]; - invalidHostnames.forEach((hostname) => { - // @ts-ignore - Testing invalid configuration - expect(() => validateConfig({ hostname })).toThrow(XmApiError); - // @ts-ignore - Testing invalid configuration - expect(() => validateConfig({ hostname })).toThrow( - 'Invalid config: hostname must be a valid xMatters hostname (*.xmatters.com or *.xmatters.com.au)', - ); - }); -}); + invalidHostnames.forEach((hostname) => { + // @ts-ignore - Testing invalid configuration + expect(() => validateConfig({ hostname })).toThrow(XmApiError); + // @ts-ignore - Testing invalid configuration + expect(() => validateConfig({ hostname })).toThrow( + 'Invalid config: hostname must be a valid xMatters hostname (*.xmatters.com or *.xmatters.com.au)', + ); + }); + }); -Deno.test('validateConfig - valid xMatters hostname', () => { - const validHostnames = [ - 'company.xmatters.com', - 'test.xmatters.com', - 'my-org.xmatters.com', - 'company.xmatters.com.au', - 'test.xmatters.com.au', - 'my-org.xmatters.com.au', - 'sub.domain.xmatters.com', - 'sub.domain.xmatters.com.au', - ]; + await t.step('accepts valid xMatters hostnames', () => { + const validHostnames = [ + 'company.xmatters.com', + 'test.xmatters.com', + 'my-org.xmatters.com', + 'company.xmatters.com.au', + 'test.xmatters.com.au', + 'my-org.xmatters.com.au', + 'sub.domain.xmatters.com', + 'sub.domain.xmatters.com.au', + ]; - validHostnames.forEach((hostname) => { - // Need valid auth config to pass full validation - const config = { hostname, username: 'user', password: 'pass' }; - expect(() => validateConfig(config)).not.toThrow(); + validHostnames.forEach((hostname) => { + // Need valid auth config to pass full validation + const config = { hostname, username: 'user', password: 'pass' }; + expect(() => validateConfig(config)).not.toThrow(); + }); + }); }); -}); -Deno.test('validateConfig - invalid maxRetries', () => { - const baseConfig = { hostname: 'test.xmatters.com', username: 'user', password: 'pass' }; - // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ ...baseConfig, maxRetries: 'invalid' })).toThrow(XmApiError); - // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ ...baseConfig, maxRetries: 'invalid' })).toThrow( - 'maxRetries must be a non-negative integer', - ); - expect(() => validateConfig({ ...baseConfig, maxRetries: -1 })).toThrow(XmApiError); - expect(() => validateConfig({ ...baseConfig, maxRetries: -1 })).toThrow( - 'maxRetries must be a non-negative integer', - ); - expect(() => validateConfig({ ...baseConfig, maxRetries: 1.5 })).toThrow(XmApiError); - expect(() => validateConfig({ ...baseConfig, maxRetries: 1.5 })).toThrow( - 'maxRetries must be a non-negative integer', - ); -}); + await t.step('MaxRetries Validation', async (t) => { + await t.step('rejects invalid maxRetries', () => { + const baseConfig = { hostname: 'test.xmatters.com', username: 'user', password: 'pass' }; + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, maxRetries: 'invalid' })).toThrow(XmApiError); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, maxRetries: 'invalid' })).toThrow( + 'maxRetries must be a non-negative integer', + ); + expect(() => validateConfig({ ...baseConfig, maxRetries: -1 })).toThrow(XmApiError); + expect(() => validateConfig({ ...baseConfig, maxRetries: -1 })).toThrow( + 'maxRetries must be a non-negative integer', + ); + expect(() => validateConfig({ ...baseConfig, maxRetries: 1.5 })).toThrow(XmApiError); + expect(() => validateConfig({ ...baseConfig, maxRetries: 1.5 })).toThrow( + 'maxRetries must be a non-negative integer', + ); + }); -Deno.test('validateConfig - valid maxRetries', () => { - const baseConfig = { hostname: 'test.xmatters.com', username: 'user', password: 'pass' }; - expect(() => validateConfig({ ...baseConfig, maxRetries: 0 })).not.toThrow(); - expect(() => validateConfig({ ...baseConfig, maxRetries: 3 })).not.toThrow(); - expect(() => validateConfig({ ...baseConfig, maxRetries: 10 })).not.toThrow(); -}); + await t.step('accepts valid maxRetries', () => { + const baseConfig = { hostname: 'test.xmatters.com', username: 'user', password: 'pass' }; + expect(() => validateConfig({ ...baseConfig, maxRetries: 0 })).not.toThrow(); + expect(() => validateConfig({ ...baseConfig, maxRetries: 3 })).not.toThrow(); + expect(() => validateConfig({ ...baseConfig, maxRetries: 10 })).not.toThrow(); + }); + }); -Deno.test('validateConfig - no auth method provided', () => { - // @ts-ignore - Testing incomplete config - const config = { hostname: 'test.xmatters.com' }; - // @ts-ignore - Testing incomplete config - expect(() => validateConfig(config)).toThrow(XmApiError); - // @ts-ignore - Testing incomplete config - expect(() => validateConfig(config)).toThrow( - 'Must provide either basic auth credentials, authorization code, or OAuth tokens', - ); -}); + await t.step('Authentication Validation', async (t) => { + await t.step('General Auth Requirements', async (t) => { + await t.step('rejects config with no auth method', () => { + // @ts-ignore - Testing incomplete config + const config = { hostname: 'test.xmatters.com' }; + // @ts-ignore - Testing incomplete config + expect(() => validateConfig(config)).toThrow(XmApiError); + // @ts-ignore - Testing incomplete config + expect(() => validateConfig(config)).toThrow( + 'Must provide either basic auth credentials, authorization code, or OAuth tokens', + ); + }); -Deno.test('validateConfig - multiple auth methods', () => { - // @ts-ignore - Testing invalid config combination - const config = { - hostname: 'test.xmatters.com', - username: 'user', - password: 'pass', - authorizationCode: 'code', - clientId: 'client', - }; - expect(() => validateConfig(config)).toThrow(XmApiError); - expect(() => validateConfig(config)).toThrow( - 'Cannot mix basic auth, authorization code, and OAuth token fields', - ); -}); + await t.step('rejects config with multiple auth methods', () => { + // @ts-ignore - Testing invalid config combination + const config = { + hostname: 'test.xmatters.com', + username: 'user', + password: 'pass', + authorizationCode: 'code', + clientId: 'client', + }; + expect(() => validateConfig(config)).toThrow(XmApiError); + expect(() => validateConfig(config)).toThrow( + 'Cannot mix basic auth, authorization code, and OAuth token fields', + ); + }); + }); -Deno.test('validateConfig - basic auth validation', () => { - const baseConfig = { hostname: 'test.xmatters.com' }; - // Invalid username types - // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ ...baseConfig, username: 123, password: 'pass' })).toThrow( - XmApiError, - ); - // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ ...baseConfig, username: 123, password: 'pass' })).toThrow( - 'username must be a non-empty string', - ); - // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ ...baseConfig, username: null, password: 'pass' })).toThrow( - XmApiError, - ); - // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ ...baseConfig, username: null, password: 'pass' })).toThrow( - 'username must be a non-empty string', - ); - // Empty username - expect(() => validateConfig({ ...baseConfig, username: '', password: 'pass' })).toThrow( - XmApiError, - ); - expect(() => validateConfig({ ...baseConfig, username: '', password: 'pass' })).toThrow( - 'username must be a non-empty string', - ); - // Invalid password types - // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ ...baseConfig, username: 'user', password: 123 })).toThrow( - XmApiError, - ); - // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ ...baseConfig, username: 'user', password: 123 })).toThrow( - 'password must be a non-empty string', - ); - // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ ...baseConfig, username: 'user', password: null })).toThrow( - XmApiError, - ); - // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ ...baseConfig, username: 'user', password: null })).toThrow( - 'password must be a non-empty string', - ); - // Empty password - expect(() => validateConfig({ ...baseConfig, username: 'user', password: '' })).toThrow( - XmApiError, - ); - expect(() => validateConfig({ ...baseConfig, username: 'user', password: '' })).toThrow( - 'password must be a non-empty string', - ); -}); + await t.step('Basic Auth Validation', async (t) => { + await t.step('rejects invalid basic auth credentials', () => { + const baseConfig = { hostname: 'test.xmatters.com' }; + // Invalid username types + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, username: 123, password: 'pass' })).toThrow( + XmApiError, + ); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, username: 123, password: 'pass' })).toThrow( + 'username must be a non-empty string', + ); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, username: null, password: 'pass' })).toThrow( + XmApiError, + ); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, username: null, password: 'pass' })).toThrow( + 'username must be a non-empty string', + ); + // Empty username + expect(() => validateConfig({ ...baseConfig, username: '', password: 'pass' })).toThrow( + XmApiError, + ); + expect(() => validateConfig({ ...baseConfig, username: '', password: 'pass' })).toThrow( + 'username must be a non-empty string', + ); + // Invalid password types + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, username: 'user', password: 123 })).toThrow( + XmApiError, + ); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, username: 'user', password: 123 })).toThrow( + 'password must be a non-empty string', + ); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, username: 'user', password: null })).toThrow( + XmApiError, + ); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, username: 'user', password: null })).toThrow( + 'password must be a non-empty string', + ); + // Empty password + expect(() => validateConfig({ ...baseConfig, username: 'user', password: '' })).toThrow( + XmApiError, + ); + expect(() => validateConfig({ ...baseConfig, username: 'user', password: '' })).toThrow( + 'password must be a non-empty string', + ); + }); -Deno.test('validateConfig - valid basic auth', () => { - const config = { - hostname: 'test.xmatters.com', - username: 'user', - password: 'pass', - }; - expect(() => validateConfig(config)).not.toThrow(); -}); + await t.step('accepts valid basic auth', () => { + const config = { + hostname: 'test.xmatters.com', + username: 'user', + password: 'pass', + }; + expect(() => validateConfig(config)).not.toThrow(); + }); + }); -Deno.test('validateConfig - auth code validation', () => { - const baseConfig = { hostname: 'test.xmatters.com' }; - // Invalid authorizationCode types - // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ ...baseConfig, authorizationCode: 123, clientId: 'client' })) - .toThrow(XmApiError); - // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ ...baseConfig, authorizationCode: 123, clientId: 'client' })) - .toThrow('authorizationCode must be a non-empty string'); + await t.step('Authorization Code Validation', async (t) => { + await t.step('rejects invalid auth code configuration', () => { + const baseConfig = { hostname: 'test.xmatters.com' }; + // Invalid authorizationCode types + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, authorizationCode: 123, clientId: 'client' })) + .toThrow(XmApiError); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, authorizationCode: 123, clientId: 'client' })) + .toThrow('authorizationCode must be a non-empty string'); - // Empty authorizationCode - expect(() => validateConfig({ ...baseConfig, authorizationCode: '', clientId: 'client' })) - .toThrow(XmApiError); - expect(() => validateConfig({ ...baseConfig, authorizationCode: '', clientId: 'client' })) - .toThrow('authorizationCode must be a non-empty string'); - // Missing clientId - // @ts-ignore - Testing incomplete config - expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code' })).toThrow(XmApiError); - // @ts-ignore - Testing incomplete config - expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code' })).toThrow( - 'clientId must be a non-empty string', - ); - // Invalid clientId types - // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code', clientId: 123 })).toThrow( - XmApiError, - ); - // @ts-ignore - Testing invalid property types - expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code', clientId: 123 })).toThrow( - 'clientId must be a non-empty string', - ); - // Empty clientId - expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code', clientId: '' })).toThrow( - XmApiError, - ); - expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code', clientId: '' })).toThrow( - 'clientId must be a non-empty string', - ); - // Invalid clientSecret type (when provided) - // @ts-ignore - Testing invalid property types - expect(() => - validateConfig({ - ...baseConfig, - authorizationCode: 'code', - clientId: 'client', - // @ts-ignore - Testing invalid property types - clientSecret: 123, - }) - ).toThrow(XmApiError); - // @ts-ignore - Testing invalid property types - expect(() => - validateConfig({ - ...baseConfig, - authorizationCode: 'code', - clientId: 'client', - // @ts-ignore - Testing invalid property types - clientSecret: 123, - }) - ).toThrow('clientSecret must be a string'); -}); + // Empty authorizationCode + expect(() => validateConfig({ ...baseConfig, authorizationCode: '', clientId: 'client' })) + .toThrow(XmApiError); + expect(() => validateConfig({ ...baseConfig, authorizationCode: '', clientId: 'client' })) + .toThrow('authorizationCode must be a non-empty string'); + // Missing clientId + // @ts-ignore - Testing incomplete config + expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code' })).toThrow( + XmApiError, + ); + // @ts-ignore - Testing incomplete config + expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code' })).toThrow( + 'clientId must be a non-empty string', + ); + // Invalid clientId types + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code', clientId: 123 })) + .toThrow( + XmApiError, + ); + // @ts-ignore - Testing invalid property types + expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code', clientId: 123 })) + .toThrow( + 'clientId must be a non-empty string', + ); + // Empty clientId + expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code', clientId: '' })) + .toThrow( + XmApiError, + ); + expect(() => validateConfig({ ...baseConfig, authorizationCode: 'code', clientId: '' })) + .toThrow( + 'clientId must be a non-empty string', + ); + // Invalid clientSecret type (when provided) + // @ts-ignore - Testing invalid property types + expect(() => + validateConfig({ + ...baseConfig, + authorizationCode: 'code', + clientId: 'client', + // @ts-ignore - Testing invalid property types + clientSecret: 123, + }) + ).toThrow(XmApiError); + // @ts-ignore - Testing invalid property types + expect(() => + validateConfig({ + ...baseConfig, + authorizationCode: 'code', + clientId: 'client', + // @ts-ignore - Testing invalid property types + clientSecret: 123, + }) + ).toThrow('clientSecret must be a string'); + }); -Deno.test('validateConfig - valid auth code', () => { - const config = { - hostname: 'test.xmatters.com', - authorizationCode: 'code', - clientId: 'client', - }; - expect(() => validateConfig(config)).not.toThrow(); - // With optional clientSecret - const configWithSecret = { - ...config, - clientSecret: 'secret', - }; - expect(() => validateConfig(configWithSecret)).not.toThrow(); -}); + await t.step('accepts valid auth code configuration', () => { + const config = { + hostname: 'test.xmatters.com', + authorizationCode: 'code', + clientId: 'client', + }; + expect(() => validateConfig(config)).not.toThrow(); + // With optional clientSecret + const configWithSecret = { + ...config, + clientSecret: 'secret', + }; + expect(() => validateConfig(configWithSecret)).not.toThrow(); + }); + }); -Deno.test('validateConfig - OAuth tokens validation', () => { - const baseConfig = { hostname: 'test.xmatters.com' }; - // Invalid accessToken types - // @ts-ignore - Testing invalid property types - expect(() => - validateConfig({ - ...baseConfig, - // @ts-ignore - Testing invalid property types - accessToken: 123, - refreshToken: 'refresh', - clientId: 'client', - }) - ).toThrow(XmApiError); - // @ts-ignore - Testing invalid property types - expect(() => - validateConfig({ - ...baseConfig, - // @ts-ignore - Testing invalid property types - accessToken: 123, - refreshToken: 'refresh', - clientId: 'client', - }) - ).toThrow('accessToken must be a non-empty string'); - // Empty accessToken - expect(() => - validateConfig({ ...baseConfig, accessToken: '', refreshToken: 'refresh', clientId: 'client' }) - ).toThrow(XmApiError); - expect(() => - validateConfig({ ...baseConfig, accessToken: '', refreshToken: 'refresh', clientId: 'client' }) - ).toThrow('accessToken must be a non-empty string'); - // Invalid refreshToken types - // @ts-ignore - Testing invalid property types - expect(() => - validateConfig({ - ...baseConfig, - accessToken: 'access', - // @ts-ignore - Testing invalid property types - refreshToken: 123, - clientId: 'client', - }) - ).toThrow(XmApiError); - // @ts-ignore - Testing invalid property types - expect(() => - validateConfig({ - ...baseConfig, - accessToken: 'access', - // @ts-ignore - Testing invalid property types - refreshToken: 123, - clientId: 'client', - }) - ).toThrow('refreshToken must be a non-empty string'); - // Empty refreshToken - expect(() => - validateConfig({ ...baseConfig, accessToken: 'access', refreshToken: '', clientId: 'client' }) - ).toThrow(XmApiError); - expect(() => - validateConfig({ ...baseConfig, accessToken: 'access', refreshToken: '', clientId: 'client' }) - ).toThrow('refreshToken must be a non-empty string'); - // Missing clientId - // @ts-ignore - Testing incomplete config - expect(() => validateConfig({ ...baseConfig, accessToken: 'access', refreshToken: 'refresh' })) - .toThrow(XmApiError); - // @ts-ignore - Testing incomplete config - expect(() => validateConfig({ ...baseConfig, accessToken: 'access', refreshToken: 'refresh' })) - .toThrow('clientId must be a non-empty string'); - // Invalid clientId types - // @ts-ignore - Testing invalid property types - expect(() => - validateConfig({ - ...baseConfig, - accessToken: 'access', - refreshToken: 'refresh', - // @ts-ignore - Testing invalid property types - clientId: 123, - }) - ).toThrow(XmApiError); - // @ts-ignore - Testing invalid property types - expect(() => - validateConfig({ - ...baseConfig, - accessToken: 'access', - refreshToken: 'refresh', - // @ts-ignore - Testing invalid property types - clientId: 123, - }) - ).toThrow('clientId must be a non-empty string'); - // Empty clientId - expect(() => - validateConfig({ ...baseConfig, accessToken: 'access', refreshToken: 'refresh', clientId: '' }) - ).toThrow(XmApiError); - expect(() => - validateConfig({ ...baseConfig, accessToken: 'access', refreshToken: 'refresh', clientId: '' }) - ).toThrow('clientId must be a non-empty string'); -}); + await t.step('OAuth Tokens Validation', async (t) => { + await t.step('rejects invalid OAuth token configuration', () => { + const baseConfig = { hostname: 'test.xmatters.com' }; + // Invalid accessToken types + // @ts-ignore - Testing invalid property types + expect(() => + validateConfig({ + ...baseConfig, + // @ts-ignore - Testing invalid property types + accessToken: 123, + refreshToken: 'refresh', + clientId: 'client', + }) + ).toThrow(XmApiError); + // @ts-ignore - Testing invalid property types + expect(() => + validateConfig({ + ...baseConfig, + // @ts-ignore - Testing invalid property types + accessToken: 123, + refreshToken: 'refresh', + clientId: 'client', + }) + ).toThrow('accessToken must be a non-empty string'); + // Empty accessToken + expect(() => + validateConfig({ + ...baseConfig, + accessToken: '', + refreshToken: 'refresh', + clientId: 'client', + }) + ).toThrow(XmApiError); + expect(() => + validateConfig({ + ...baseConfig, + accessToken: '', + refreshToken: 'refresh', + clientId: 'client', + }) + ).toThrow('accessToken must be a non-empty string'); + // Invalid refreshToken types + // @ts-ignore - Testing invalid property types + expect(() => + validateConfig({ + ...baseConfig, + accessToken: 'access', + // @ts-ignore - Testing invalid property types + refreshToken: 123, + clientId: 'client', + }) + ).toThrow(XmApiError); + // @ts-ignore - Testing invalid property types + expect(() => + validateConfig({ + ...baseConfig, + accessToken: 'access', + // @ts-ignore - Testing invalid property types + refreshToken: 123, + clientId: 'client', + }) + ).toThrow('refreshToken must be a non-empty string'); + // Empty refreshToken + expect(() => + validateConfig({ + ...baseConfig, + accessToken: 'access', + refreshToken: '', + clientId: 'client', + }) + ).toThrow(XmApiError); + expect(() => + validateConfig({ + ...baseConfig, + accessToken: 'access', + refreshToken: '', + clientId: 'client', + }) + ).toThrow('refreshToken must be a non-empty string'); + // Missing clientId + // @ts-ignore - Testing incomplete config + expect(() => + // @ts-ignore - Testing incomplete config + validateConfig({ ...baseConfig, accessToken: 'access', refreshToken: 'refresh' }) + ) + .toThrow(XmApiError); + // @ts-ignore - Testing incomplete config + expect(() => + // @ts-ignore - Testing incomplete config + validateConfig({ ...baseConfig, accessToken: 'access', refreshToken: 'refresh' }) + ) + .toThrow('clientId must be a non-empty string'); + // Invalid clientId types + // @ts-ignore - Testing invalid property types + expect(() => + validateConfig({ + ...baseConfig, + accessToken: 'access', + refreshToken: 'refresh', + // @ts-ignore - Testing invalid property types + clientId: 123, + }) + ).toThrow(XmApiError); + // @ts-ignore - Testing invalid property types + expect(() => + validateConfig({ + ...baseConfig, + accessToken: 'access', + refreshToken: 'refresh', + // @ts-ignore - Testing invalid property types + clientId: 123, + }) + ).toThrow('clientId must be a non-empty string'); + // Empty clientId + expect(() => + validateConfig({ + ...baseConfig, + accessToken: 'access', + refreshToken: 'refresh', + clientId: '', + }) + ).toThrow(XmApiError); + expect(() => + validateConfig({ + ...baseConfig, + accessToken: 'access', + refreshToken: 'refresh', + clientId: '', + }) + ).toThrow('clientId must be a non-empty string'); + }); -Deno.test('validateConfig - valid OAuth tokens', () => { - const config = { - hostname: 'test.xmatters.com', - accessToken: 'access', - refreshToken: 'refresh', - clientId: 'client', - }; - expect(() => validateConfig(config)).not.toThrow(); -}); + await t.step('accepts valid OAuth tokens configuration', () => { + const config = { + hostname: 'test.xmatters.com', + accessToken: 'access', + refreshToken: 'refresh', + clientId: 'client', + }; + expect(() => validateConfig(config)).not.toThrow(); + }); + }); + }); -Deno.test('validateConfig - edge cases', () => { - // maxRetries undefined should be fine - const config = { - hostname: 'test.xmatters.com', - username: 'user', - password: 'pass', - maxRetries: undefined, - }; - expect(() => validateConfig(config)).not.toThrow(); - // clientSecret undefined should be fine - const authCodeConfig = { - hostname: 'test.xmatters.com', - authorizationCode: 'code', - clientId: 'client', - clientSecret: undefined, - }; - expect(() => validateConfig(authCodeConfig)).not.toThrow(); + await t.step('Edge Cases', async (t) => { + await t.step('handles undefined optional fields', () => { + // maxRetries undefined should be fine + const config = { + hostname: 'test.xmatters.com', + username: 'user', + password: 'pass', + maxRetries: undefined, + }; + expect(() => validateConfig(config)).not.toThrow(); + // clientSecret undefined should be fine + const authCodeConfig = { + hostname: 'test.xmatters.com', + authorizationCode: 'code', + clientId: 'client', + clientSecret: undefined, + }; + expect(() => validateConfig(authCodeConfig)).not.toThrow(); + }); + }); }); From 368a7a236733761f499bbeb731c90ec759060cf6 Mon Sep 17 00:00:00 2001 From: johan Date: Thu, 26 Jun 2025 09:33:51 -0700 Subject: [PATCH 073/101] Continue refactoring unit tests --- src/core/request-builder.test.ts | 137 ++-- src/core/resource-client.test.ts | 506 ++++++------ src/index.test.ts | 1259 +++++++++++++++--------------- 3 files changed, 934 insertions(+), 968 deletions(-) diff --git a/src/core/request-builder.test.ts b/src/core/request-builder.test.ts index ecd602b..3e0950d 100644 --- a/src/core/request-builder.test.ts +++ b/src/core/request-builder.test.ts @@ -22,35 +22,17 @@ function createRequestBuilderTestSetup(options: { return { builder }; } -// Mock data for tests -const mockRelativePathOptions: RequestBuildOptions = { - path: '/people', - method: 'GET', - query: { search: 'test', limit: 10 }, -}; - -const mockExternalUrlOptions: RequestBuildOptions = { - fullUrl: 'https://api.external-service.com/v2/endpoint', - method: 'POST', - query: { key: 'value' }, - headers: { 'Authorization': 'Bearer token' }, -}; - -const mockCustomHeadersOptions: RequestBuildOptions = { - path: '/groups', - method: 'PUT', - headers: { - 'custom-header': 'custom-value', - 'default-header': 'overridden-value', // Should override default - }, - body: { name: 'test-group' }, -}; - Deno.test('RequestBuilder', async (t) => { + // Create shared builder instance for most tests + const { builder } = createRequestBuilderTestSetup(); + await t.step('URL Construction', async (t) => { await t.step('builds request with relative path - verifies correct URL construction', () => { - const { builder } = createRequestBuilderTestSetup(); - const request = builder.build(mockRelativePathOptions); + const request = builder.build({ + path: '/people', + method: 'GET', + query: { search: 'test', limit: 10 }, + }); expect(request.url).toBe('https://example.xmatters.com/api/xm/1/people?search=test&limit=10'); expect(request.method).toBe('GET'); expect(request.headers?.['Content-Type']).toBe('application/json'); @@ -60,8 +42,12 @@ Deno.test('RequestBuilder', async (t) => { }); await t.step('builds request with external URL - bypasses API version path', () => { - const { builder } = createRequestBuilderTestSetup(); - const request = builder.build(mockExternalUrlOptions); + const request = builder.build({ + fullUrl: 'https://api.external-service.com/v2/endpoint', + method: 'POST', + query: { key: 'value' }, + headers: { 'Authorization': 'Bearer token' }, + }); expect(request.url).toBe('https://api.external-service.com/v2/endpoint?key=value'); expect(request.method).toBe('POST'); expect(request.headers?.['Content-Type']).toBe('application/json'); @@ -70,7 +56,6 @@ Deno.test('RequestBuilder', async (t) => { }); await t.step('preserves existing query parameters in external URLs', () => { - const { builder } = createRequestBuilderTestSetup(); const options: RequestBuildOptions = { fullUrl: 'https://api.external-service.com/search?existing=param&another=value', query: { additional: 'param', new: 'value' }, @@ -84,21 +69,29 @@ Deno.test('RequestBuilder', async (t) => { }); await t.step('works with custom hostname configuration', () => { - const { builder } = createRequestBuilderTestSetup({ + // This test needs its own builder with custom hostname + const { builder: customBuilder } = createRequestBuilderTestSetup({ hostname: 'https://custom.xmatters.com', }); const options: RequestBuildOptions = { path: '/notifications', }; - const request = builder.build(options); + const request = customBuilder.build(options); expect(request.url).toBe('https://custom.xmatters.com/api/xm/1/notifications'); }); }); await t.step('Header Management', async (t) => { await t.step('merges headers correctly - request headers override defaults', () => { - const { builder } = createRequestBuilderTestSetup(); - const request = builder.build(mockCustomHeadersOptions); + const request = builder.build({ + path: '/groups', + method: 'PUT', + headers: { + 'custom-header': 'custom-value', + 'default-header': 'overridden-value', // Should override default + }, + body: { name: 'test-group' }, + }); expect(request.headers?.['Content-Type']).toBe('application/json'); expect(request.headers?.['Accept']).toBe('application/json'); expect(request.headers?.['default-header']).toBe('overridden-value'); // Overridden @@ -108,21 +101,21 @@ Deno.test('RequestBuilder', async (t) => { }); await t.step('works with empty default headers', () => { - const { builder } = createRequestBuilderTestSetup({ + // This test needs its own builder with empty default headers + const { builder: emptyHeadersBuilder } = createRequestBuilderTestSetup({ defaultHeaders: {}, }); const options: RequestBuildOptions = { path: '/sites', headers: { 'Custom-Header': 'value' }, }; - const request = builder.build(options); + const request = emptyHeadersBuilder.build(options); expect(request.headers).toEqual({ 'Custom-Header': 'value' }); }); }); await t.step('Query Parameter Handling', async (t) => { await t.step('handles empty query object', () => { - const { builder } = createRequestBuilderTestSetup(); const options: RequestBuildOptions = { path: '/devices', query: {}, @@ -132,7 +125,6 @@ Deno.test('RequestBuilder', async (t) => { }); await t.step('filters out null and undefined query parameters', () => { - const { builder } = createRequestBuilderTestSetup(); const options: RequestBuildOptions = { path: '/events', query: { @@ -151,7 +143,6 @@ Deno.test('RequestBuilder', async (t) => { }); await t.step('handles array query parameters by joining with commas', () => { - const { builder } = createRequestBuilderTestSetup(); const options: RequestBuildOptions = { path: '/groups/123', query: { @@ -168,7 +159,6 @@ Deno.test('RequestBuilder', async (t) => { }); await t.step('handles empty arrays gracefully', () => { - const { builder } = createRequestBuilderTestSetup(); const options: RequestBuildOptions = { path: '/groups', query: { @@ -183,7 +173,6 @@ Deno.test('RequestBuilder', async (t) => { }); await t.step('handles mixed array types by converting to strings', () => { - const { builder } = createRequestBuilderTestSetup(); const options: RequestBuildOptions = { path: '/items', query: { @@ -202,7 +191,6 @@ Deno.test('RequestBuilder', async (t) => { await t.step('Default Behavior', async (t) => { await t.step('defaults method to GET when not specified', () => { - const { builder } = createRequestBuilderTestSetup(); const options: RequestBuildOptions = { path: '/users', }; @@ -212,7 +200,6 @@ Deno.test('RequestBuilder', async (t) => { }); await t.step('preserves retry attempt when provided', () => { - const { builder } = createRequestBuilderTestSetup(); const options: RequestBuildOptions = { path: '/shifts', retryAttempt: 2, @@ -224,53 +211,55 @@ Deno.test('RequestBuilder', async (t) => { await t.step('Error Handling', async (t) => { await t.step('throws when path does not start with slash', () => { - const { builder } = createRequestBuilderTestSetup(); - let thrownError: unknown; - try { - builder.build({ path: 'people' }); - } catch (error) { - thrownError = error; - } + let thrownError: XmApiError | undefined; + expect(() => { + try { + builder.build({ path: 'people' }); + } catch (e) { + thrownError = e as XmApiError; + throw e; // Re-throw for expect().toThrow() + } + }).toThrow(); expect(thrownError).toBeInstanceOf(XmApiError); - const error = thrownError as XmApiError; - expect(error.message).toBe('Path must start with a forward slash, e.g. "/people"'); + expect(thrownError?.message).toBe('Path must start with a forward slash, e.g. "/people"'); }); await t.step('throws when both path and fullUrl are provided', () => { - const { builder } = createRequestBuilderTestSetup(); - let thrownError: unknown; - try { - builder.build({ - path: '/people', - fullUrl: 'https://api.external-service.com/v2/endpoint', - }); - } catch (error) { - thrownError = error; - } + let thrownError: XmApiError | undefined; + expect(() => { + try { + builder.build({ + path: '/people', + fullUrl: 'https://api.external-service.com/v2/endpoint', + }); + } catch (e) { + thrownError = e as XmApiError; + throw e; // Re-throw for expect().toThrow() + } + }).toThrow(); expect(thrownError).toBeInstanceOf(XmApiError); - const error = thrownError as XmApiError; - expect(error.message).toBe( + expect(thrownError?.message).toBe( 'Cannot specify both fullUrl and path. Use fullUrl for external endpoints, path for xMatters API endpoints.', ); }); await t.step('throws when neither path nor fullUrl is provided', () => { - const { builder } = createRequestBuilderTestSetup(); - let thrownError: unknown; - try { - builder.build({}); - } catch (error) { - thrownError = error; - } + let thrownError: XmApiError | undefined; + expect(() => { + try { + builder.build({}); + } catch (e) { + thrownError = e as XmApiError; + throw e; // Re-throw for expect().toThrow() + } + }).toThrow(); expect(thrownError).toBeInstanceOf(XmApiError); - const error = thrownError as XmApiError; - expect(error.message).toBe('Either path or fullUrl must be provided'); + expect(thrownError?.message).toBe('Either path or fullUrl must be provided'); }); }); await t.step('Integration Tests', async (t) => { await t.step('builds complex request with all options', () => { - const { builder } = createRequestBuilderTestSetup(); const complexOptions: RequestBuildOptions = { path: '/forms/abc123/submissions', method: 'PATCH', @@ -320,7 +309,6 @@ Deno.test('RequestBuilder', async (t) => { await t.step('verifies external URL is correctly passed to HTTP client', () => { // This test ensures that when using fullUrl, the complete external URL // (not just the path) is properly passed to the underlying HTTP client - const { builder } = createRequestBuilderTestSetup(); const request = builder.build({ fullUrl: 'https://api.external-service.com/v2/endpoint', query: { test: 'param' }, @@ -334,7 +322,6 @@ Deno.test('RequestBuilder', async (t) => { await t.step('verifies API path is correctly built with base URL', () => { // This test ensures that relative API paths are correctly combined with the base URL - const { builder } = createRequestBuilderTestSetup(); const request = builder.build({ path: '/groups', query: { search: 'test' }, diff --git a/src/core/resource-client.test.ts b/src/core/resource-client.test.ts index a2cb735..162b90a 100644 --- a/src/core/resource-client.test.ts +++ b/src/core/resource-client.test.ts @@ -23,9 +23,8 @@ function createResourceClientTestSetup(basePath: string) { } Deno.test('ResourceClient', async (t) => { - await t.step( - 'Constructor validation - throws XmApiError when base path does not start with slash', - () => { + await t.step('Constructor Validation', async (t) => { + await t.step('throws XmApiError when base path does not start with slash', () => { const { requestHandler } = createResourceClientTestSetup('/valid'); let thrownError: unknown; try { @@ -37,280 +36,287 @@ Deno.test('ResourceClient', async (t) => { const error = thrownError as XmApiError; expect(error.message).toBe('Base path must start with a /'); expect(error.response).toBeUndefined(); // This is a validation error, not an HTTP error - }, - ); + }); - await t.step('Constructor validation - accepts valid base path starting with slash', () => { - const { createResourceClient } = createResourceClientTestSetup('/groups'); - expect(() => createResourceClient()).not.toThrow(); + await t.step('accepts valid base path starting with slash', () => { + const { createResourceClient } = createResourceClientTestSetup('/groups'); + expect(() => createResourceClient()).not.toThrow(); + }); }); - await t.step('get() - prepends base path to relative path', async () => { - const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); - const client = createResourceClient(); - // Mock successful response - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups/members', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + await t.step('GET Requests', async (t) => { + await t.step('prepends base path to relative path', async () => { + const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); + const client = createResourceClient(); + // Mock successful response + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups/members', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, }, - }, - mockedResponse: { - status: 200, - headers: { 'content-type': 'application/json' }, - body: { success: true }, - }, - }]); - await client.get({ path: 'members' }); - expect(mockHttpClient.requests).toHaveLength(1); - expect(mockHttpClient.requests[0].url).toBe( - 'https://test.xmatters.com/api/xm/1/groups/members', - ); - mockHttpClient.verifyAllRequestsMade(); - }); + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { success: true }, + }, + }]); + await client.get({ path: 'members' }); + expect(mockHttpClient.requests).toHaveLength(1); + expect(mockHttpClient.requests[0].url).toBe( + 'https://test.xmatters.com/api/xm/1/groups/members', + ); + mockHttpClient.verifyAllRequestsMade(); + }); - await t.step('get() - uses base path when no path provided', async () => { - const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); - const client = createResourceClient(); - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + await t.step('uses base path when no path provided', async () => { + const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); + const client = createResourceClient(); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, }, - }, - mockedResponse: { - status: 200, - headers: { 'content-type': 'application/json' }, - body: { success: true }, - }, - }]); - await client.get({}); - expect(mockHttpClient.requests).toHaveLength(1); - expect(mockHttpClient.requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups'); - mockHttpClient.verifyAllRequestsMade(); - }); + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { success: true }, + }, + }]); + await client.get({}); + expect(mockHttpClient.requests).toHaveLength(1); + expect(mockHttpClient.requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups'); + mockHttpClient.verifyAllRequestsMade(); + }); - await t.step('get() - strips leading slash from provided path', async () => { - const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); - const client = createResourceClient(); - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups/members', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + await t.step('strips leading slash from provided path', async () => { + const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); + const client = createResourceClient(); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups/members', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { success: true }, }, - }, - mockedResponse: { - status: 200, - headers: { 'content-type': 'application/json' }, - body: { success: true }, - }, - }]); - await client.get({ path: '/members' }); - expect(mockHttpClient.requests).toHaveLength(1); - expect(mockHttpClient.requests[0].url).toBe( - 'https://test.xmatters.com/api/xm/1/groups/members', - ); - mockHttpClient.verifyAllRequestsMade(); + }]); + await client.get({ path: '/members' }); + expect(mockHttpClient.requests).toHaveLength(1); + expect(mockHttpClient.requests[0].url).toBe( + 'https://test.xmatters.com/api/xm/1/groups/members', + ); + mockHttpClient.verifyAllRequestsMade(); + }); }); - await t.step('post() - prepends base path correctly', async () => { - const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); - const client = createResourceClient(); - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'POST', - url: 'https://test.xmatters.com/api/xm/1/groups/new-group', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + await t.step('HTTP Method Support', async (t) => { + await t.step('POST - prepends base path correctly', async () => { + const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); + const client = createResourceClient(); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/groups/new-group', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + body: { name: 'Test Group' }, + }, + mockedResponse: { + status: 201, + headers: { 'content-type': 'application/json' }, + body: { id: '123' }, }, + }]); + await client.post({ + path: 'new-group', body: { name: 'Test Group' }, - }, - mockedResponse: { - status: 201, - headers: { 'content-type': 'application/json' }, - body: { id: '123' }, - }, - }]); - await client.post({ - path: 'new-group', - body: { name: 'Test Group' }, + }); + expect(mockHttpClient.requests).toHaveLength(1); + expect(mockHttpClient.requests[0].url).toBe( + 'https://test.xmatters.com/api/xm/1/groups/new-group', + ); + expect(mockHttpClient.requests[0].method).toBe('POST'); + mockHttpClient.verifyAllRequestsMade(); }); - expect(mockHttpClient.requests).toHaveLength(1); - expect(mockHttpClient.requests[0].url).toBe( - 'https://test.xmatters.com/api/xm/1/groups/new-group', - ); - expect(mockHttpClient.requests[0].method).toBe('POST'); - mockHttpClient.verifyAllRequestsMade(); - }); - await t.step('put() - prepends base path correctly', async () => { - const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); - const client = createResourceClient(); - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'PUT', - url: 'https://test.xmatters.com/api/xm/1/groups/123', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + await t.step('PUT - prepends base path correctly', async () => { + const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); + const client = createResourceClient(); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'PUT', + url: 'https://test.xmatters.com/api/xm/1/groups/123', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + body: { name: 'Updated Group' }, }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { id: '123' }, + }, + }]); + await client.put({ + path: '123', body: { name: 'Updated Group' }, - }, - mockedResponse: { - status: 200, - headers: { 'content-type': 'application/json' }, - body: { id: '123' }, - }, - }]); - await client.put({ - path: '123', - body: { name: 'Updated Group' }, + }); + expect(mockHttpClient.requests).toHaveLength(1); + expect(mockHttpClient.requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/123'); + expect(mockHttpClient.requests[0].method).toBe('PUT'); + mockHttpClient.verifyAllRequestsMade(); }); - expect(mockHttpClient.requests).toHaveLength(1); - expect(mockHttpClient.requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/123'); - expect(mockHttpClient.requests[0].method).toBe('PUT'); - mockHttpClient.verifyAllRequestsMade(); - }); - await t.step('patch() - prepends base path correctly', async () => { - const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); - const client = createResourceClient(); - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'PATCH', - url: 'https://test.xmatters.com/api/xm/1/groups/123', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + + await t.step('PATCH - prepends base path correctly', async () => { + const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); + const client = createResourceClient(); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'PATCH', + url: 'https://test.xmatters.com/api/xm/1/groups/123', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + body: { name: 'Patched Group' }, }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { id: '123' }, + }, + }]); + await client.patch({ + path: '123', body: { name: 'Patched Group' }, - }, - mockedResponse: { - status: 200, - headers: { 'content-type': 'application/json' }, - body: { id: '123' }, - }, - }]); - await client.patch({ - path: '123', - body: { name: 'Patched Group' }, + }); + expect(mockHttpClient.requests).toHaveLength(1); + expect(mockHttpClient.requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/123'); + expect(mockHttpClient.requests[0].method).toBe('PATCH'); + mockHttpClient.verifyAllRequestsMade(); }); - expect(mockHttpClient.requests).toHaveLength(1); - expect(mockHttpClient.requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/123'); - expect(mockHttpClient.requests[0].method).toBe('PATCH'); - mockHttpClient.verifyAllRequestsMade(); - }); - await t.step('delete() - prepends base path correctly', async () => { - const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); - const client = createResourceClient(); - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'DELETE', - url: 'https://test.xmatters.com/api/xm/1/groups/123', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + await t.step('DELETE - prepends base path correctly', async () => { + const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); + const client = createResourceClient(); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'DELETE', + url: 'https://test.xmatters.com/api/xm/1/groups/123', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 204, + headers: {}, + body: '', }, - }, - mockedResponse: { - status: 204, - headers: {}, - body: '', - }, - }]); - await client.delete({ path: '123' }); - expect(mockHttpClient.requests).toHaveLength(1); - expect(mockHttpClient.requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/123'); - expect(mockHttpClient.requests[0].method).toBe('DELETE'); - mockHttpClient.verifyAllRequestsMade(); + }]); + await client.delete({ path: '123' }); + expect(mockHttpClient.requests).toHaveLength(1); + expect(mockHttpClient.requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/123'); + expect(mockHttpClient.requests[0].method).toBe('DELETE'); + mockHttpClient.verifyAllRequestsMade(); + }); }); - await t.step('Complex path building - handles nested paths correctly', async () => { - const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); - const client = createResourceClient(); - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups/123/members/456', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + await t.step('Advanced Path Handling', async (t) => { + await t.step('handles nested paths correctly', async () => { + const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); + const client = createResourceClient(); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups/123/members/456', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, }, - }, - mockedResponse: { - status: 200, - headers: { 'content-type': 'application/json' }, - body: { success: true }, - }, - }]); - await client.get({ path: '123/members/456' }); - expect(mockHttpClient.requests).toHaveLength(1); - expect(mockHttpClient.requests[0].url).toBe( - 'https://test.xmatters.com/api/xm/1/groups/123/members/456', - ); - mockHttpClient.verifyAllRequestsMade(); - }); + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { success: true }, + }, + }]); + await client.get({ path: '123/members/456' }); + expect(mockHttpClient.requests).toHaveLength(1); + expect(mockHttpClient.requests[0].url).toBe( + 'https://test.xmatters.com/api/xm/1/groups/123/members/456', + ); + mockHttpClient.verifyAllRequestsMade(); + }); - await t.step('Passes through all other options unchanged', async () => { - const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); - const client = createResourceClient(); - const testHeaders = { 'Custom-Header': 'test-value' }; - const testQuery = { page: '1', limit: '10' }; - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups/members?page=1&limit=10', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - 'Custom-Header': 'test-value', + await t.step('passes through all other options unchanged', async () => { + const { mockHttpClient, createResourceClient } = createResourceClientTestSetup('/groups'); + const client = createResourceClient(); + const testHeaders = { 'Custom-Header': 'test-value' }; + const testQuery = { page: '1', limit: '10' }; + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups/members?page=1&limit=10', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + 'Custom-Header': 'test-value', + }, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { success: true }, }, - }, - mockedResponse: { - status: 200, - headers: { 'content-type': 'application/json' }, - body: { success: true }, - }, - }]); - await client.get({ - path: 'members', - headers: testHeaders, - query: testQuery, + }]); + await client.get({ + path: 'members', + headers: testHeaders, + query: testQuery, + }); + expect(mockHttpClient.requests).toHaveLength(1); + // Check that custom headers are included + expect(mockHttpClient.requests[0].headers?.['Custom-Header']).toBe('test-value'); + expect(mockHttpClient.requests[0].url).toBe( + 'https://test.xmatters.com/api/xm/1/groups/members?page=1&limit=10', + ); + mockHttpClient.verifyAllRequestsMade(); }); - expect(mockHttpClient.requests).toHaveLength(1); - // Check that custom headers are included - expect(mockHttpClient.requests[0].headers?.['Custom-Header']).toBe('test-value'); - expect(mockHttpClient.requests[0].url).toBe( - 'https://test.xmatters.com/api/xm/1/groups/members?page=1&limit=10', - ); - mockHttpClient.verifyAllRequestsMade(); }); }); diff --git a/src/index.test.ts b/src/index.test.ts index 9f18c3a..a199958 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -5,139 +5,56 @@ import { MockHttpClient, MockLogger, withFakeTime } from './core/test-utils.ts'; const mockHttpClient = new MockHttpClient(); const mockLogger = new MockLogger(); -Deno.test('XmApi', async (t) => { - await t.step('Basic Auth Integration', async () => { - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, - }); - // Test a simple GET request - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups?limit=10', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', // base64 of testuser:testpass - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, - }, - mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: { count: 0, total: 0, data: [] }, - }, - }]); - const response = await api.groups.get({ query: { limit: 10 } }); - expect(response.status).toBe(200); - expect(response.body.count).toBe(0); - mockHttpClient.verifyAllRequestsMade(); - }); - - await t.step('OAuth Token Integration', async () => { - const api = new XmApi({ - hostname: 'test.xmatters.com', - accessToken: 'test-access-token', - refreshToken: 'test-refresh-token', - clientId: 'test-client-id', - httpClient: mockHttpClient, - logger: mockLogger, - }); - // Test OAuth Bearer token is used - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Bearer test-access-token', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, - }, - mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: { count: 1, total: 1, data: [{ id: '123', targetName: 'Test Group' }] }, - }, - }]); - const response = await api.groups.get(); - expect(response.status).toBe(200); - expect(response.body.data).toHaveLength(1); - mockHttpClient.verifyAllRequestsMade(); - }); - - await t.step('Token Refresh on 401', async () => { - let tokenRefreshCalled = false; - let newAccessToken = ''; - let newRefreshToken = ''; - const api = new XmApi({ - hostname: 'test.xmatters.com', - accessToken: 'expired-token', - refreshToken: 'valid-refresh-token', - clientId: 'test-client-id', - httpClient: mockHttpClient, - logger: mockLogger, - onTokenRefresh: (accessToken, refreshToken) => { - tokenRefreshCalled = true; - newAccessToken = accessToken; - newRefreshToken = refreshToken; - }, - }); - mockHttpClient.setReqRes([ - // First request fails with 401 - { +Deno.test('XmApi Integration Tests', async (t) => { + await t.step('Authentication', async (t) => { + await t.step('Basic Auth Integration', async () => { + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + }); + // Test a simple GET request + mockHttpClient.setReqRes([{ expectedRequest: { method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', + url: 'https://test.xmatters.com/api/xm/1/groups?limit=10', headers: { - 'Authorization': 'Bearer expired-token', + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', // base64 of testuser:testpass 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, - mockedResponse: { - status: 401, - headers: { 'Content-Type': 'application/json' }, - body: { error: 'Unauthorized' }, - }, - }, - // Token refresh request - { - expectedRequest: { - method: 'POST', - url: 'https://test.xmatters.com/api/xm/1/oauth2/token', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, - body: - 'grant_type=refresh_token&refresh_token=valid-refresh-token&client_id=test-client-id', - }, mockedResponse: { status: 200, headers: { 'Content-Type': 'application/json' }, - body: { - access_token: 'new-access-token', - refresh_token: 'new-refresh-token', - token_type: 'bearer', - expires_in: 3600, - }, + body: { count: 0, total: 0, data: [] }, }, - }, - // Retry original request with new token - { + }]); + const response = await api.groups.get({ query: { limit: 10 } }); + expect(response.status).toBe(200); + expect(response.body.count).toBe(0); + mockHttpClient.verifyAllRequestsMade(); + }); + + await t.step('OAuth Token Integration', async () => { + const api = new XmApi({ + hostname: 'test.xmatters.com', + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + clientId: 'test-client-id', + httpClient: mockHttpClient, + logger: mockLogger, + }); + // Test OAuth Bearer token is used + mockHttpClient.setReqRes([{ expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', headers: { - 'Authorization': 'Bearer new-access-token', + 'Authorization': 'Bearer test-access-token', 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', @@ -148,181 +65,80 @@ Deno.test('XmApi', async (t) => { headers: { 'Content-Type': 'application/json' }, body: { count: 1, total: 1, data: [{ id: '123', targetName: 'Test Group' }] }, }, - }, - ]); - const response = await api.groups.get(); - expect(response.status).toBe(200); - expect(tokenRefreshCalled).toBe(true); - expect(newAccessToken).toBe('new-access-token'); - expect(newRefreshToken).toBe('new-refresh-token'); - mockHttpClient.verifyAllRequestsMade(); - }); - - await t.step('Token Refresh Callback Error Handling', async () => { - // Test that callback errors are logged as warnings but don't break the flow - mockLogger.setExpectedLogs([ - { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, - { level: 'debug', message: /^<-- 401 \(\d+ms\)$/ }, - { level: 'debug', message: 'Refreshing token for client test-client-id' }, - { level: 'debug', message: '--> POST https://test.xmatters.com/api/xm/1/oauth2/token' }, - { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, - { - level: 'warn', - message: 'Error in onTokenRefresh callback, but continuing with refreshed token', - }, - { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, - { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, - ]); - const api = new XmApi({ - hostname: 'test.xmatters.com', - accessToken: 'expired-token', - refreshToken: 'valid-refresh-token', - clientId: 'test-client-id', - httpClient: mockHttpClient, - logger: mockLogger, - onTokenRefresh: () => { - throw new Error('Callback error'); - }, + }]); + const response = await api.groups.get(); + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(1); + mockHttpClient.verifyAllRequestsMade(); }); - mockHttpClient.setReqRes([ - // First request fails with 401 - { - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Bearer expired-token', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, - }, - mockedResponse: { - status: 401, - headers: { 'Content-Type': 'application/json' }, - body: { error: 'Unauthorized' }, - }, - }, - // Token refresh request - { - expectedRequest: { - method: 'POST', - url: 'https://test.xmatters.com/api/xm/1/oauth2/token', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, - body: - 'grant_type=refresh_token&refresh_token=valid-refresh-token&client_id=test-client-id', - }, - mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: { - access_token: 'new-access-token', - refresh_token: 'new-refresh-token', - token_type: 'bearer', - expires_in: 3600, - }, - }, - }, - // Retry original request with new token - { - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Bearer new-access-token', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, - }, - mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: { count: 0, total: 0, data: [] }, - }, - }, - ]); - // Should not throw despite callback error - const response = await api.groups.get(); - expect(response.status).toBe(200); - mockHttpClient.verifyAllRequestsMade(); - mockLogger.verifyAllLogsLogged(); - }); - await t.step('Retry Logic for 429 Rate Limit', async () => { - return await withFakeTime(async (fakeTime) => { - mockLogger.setExpectedLogs([ - { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, - { level: 'debug', message: /^<-- 429 \(\d+ms\)$/ }, - { - level: 'debug', - message: 'Request failed with status 429, retrying in 1000ms (attempt 1/2)', - }, - { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, - { level: 'debug', message: /^<-- 429 \(\d+ms\)$/ }, - { - level: 'debug', - message: 'Request failed with status 429, retrying in 2000ms (attempt 2/2)', - }, - { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, - { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, - ]); + await t.step('Token Refresh on 401', async () => { + let tokenRefreshCalled = false; + let newAccessToken = ''; + let newRefreshToken = ''; const api = new XmApi({ hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', + accessToken: 'expired-token', + refreshToken: 'valid-refresh-token', + clientId: 'test-client-id', httpClient: mockHttpClient, logger: mockLogger, - maxRetries: 2, + onTokenRefresh: (accessToken, refreshToken) => { + tokenRefreshCalled = true; + newAccessToken = accessToken; + newRefreshToken = refreshToken; + }, }); mockHttpClient.setReqRes([ - // First request fails with 429 rate limit + // First request fails with 401 { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Authorization': 'Bearer expired-token', 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, mockedResponse: { - status: 429, - headers: { 'Retry-After': '1' }, // Server requests 1 second delay - body: { error: 'Rate limit exceeded' }, + status: 401, + headers: { 'Content-Type': 'application/json' }, + body: { error: 'Unauthorized' }, }, }, - // First retry also fails with 429 rate limit + // Token refresh request { expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', }, + body: + 'grant_type=refresh_token&refresh_token=valid-refresh-token&client_id=test-client-id', }, mockedResponse: { - status: 429, - headers: { 'Retry-After': '1' }, - body: { error: 'Rate limit exceeded' }, + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'bearer', + expires_in: 3600, + }, }, }, - // Second retry succeeds + // Retry original request with new token { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Authorization': 'Bearer new-access-token', 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', @@ -331,79 +147,94 @@ Deno.test('XmApi', async (t) => { mockedResponse: { status: 200, headers: { 'Content-Type': 'application/json' }, - body: { count: 0, total: 0, data: [] }, + body: { count: 1, total: 1, data: [{ id: '123', targetName: 'Test Group' }] }, }, }, ]); - // Start the request without awaiting to allow fake time control - const requestPromise = api.groups.get(); - const [response] = await Promise.allSettled([ - requestPromise, - // Advance fake time to trigger scheduled retry delays - // Pattern: request -> setTimeout for retry delay -> retry -> setTimeout -> retry - fakeTime.nextAsync(), // Executes first setTimeout (1s delay), triggering first retry - fakeTime.nextAsync(), // Executes second setTimeout (1s delay), triggering second retry - ]); - if (response.status === 'fulfilled') { - expect(response.value.status).toBe(200); - } else { - throw new Error( - `TEST SETUP ERROR: Expected request to succeed for retry logic test, but it was rejected. ` + - `This likely indicates a problem with the test setup (mock expectations, fake time, etc.). ` + - `Original error: ${response.reason}`, - ); - } + const response = await api.groups.get(); + expect(response.status).toBe(200); + expect(tokenRefreshCalled).toBe(true); + expect(newAccessToken).toBe('new-access-token'); + expect(newRefreshToken).toBe('new-refresh-token'); mockHttpClient.verifyAllRequestsMade(); - mockLogger.verifyAllLogsLogged(); }); - }); - await t.step('Retry Logic for 500 Server Error', async () => { - return await withFakeTime(async (fakeTime) => { + await t.step('Token Refresh Callback Error Handling', async () => { + // Test that callback errors are logged as warnings but don't break the flow mockLogger.setExpectedLogs([ { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, - { level: 'debug', message: /^<-- 500 \(\d+ms\)$/ }, + { level: 'debug', message: /^<-- 401 \(\d+ms\)$/ }, + { level: 'debug', message: 'Refreshing token for client test-client-id' }, + { level: 'debug', message: '--> POST https://test.xmatters.com/api/xm/1/oauth2/token' }, + { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, { - level: 'debug', - message: 'Request failed with status 500, retrying in 1000ms (attempt 1/1)', + level: 'warn', + message: 'Error in onTokenRefresh callback, but continuing with refreshed token', }, { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, ]); const api = new XmApi({ hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', + accessToken: 'expired-token', + refreshToken: 'valid-refresh-token', + clientId: 'test-client-id', httpClient: mockHttpClient, logger: mockLogger, - maxRetries: 1, + onTokenRefresh: () => { + throw new Error('Callback error'); + }, }); mockHttpClient.setReqRes([ - // First request fails with 500 server error + // First request fails with 401 { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Authorization': 'Bearer expired-token', 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, mockedResponse: { - status: 500, + status: 401, headers: { 'Content-Type': 'application/json' }, - body: { error: 'Internal Server Error' }, + body: { error: 'Unauthorized' }, }, }, - // Retry succeeds after exponential backoff delay + // Token refresh request + { + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + body: + 'grant_type=refresh_token&refresh_token=valid-refresh-token&client_id=test-client-id', + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'bearer', + expires_in: 3600, + }, + }, + }, + // Retry original request with new token { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Authorization': 'Bearer new-access-token', 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', @@ -416,437 +247,579 @@ Deno.test('XmApi', async (t) => { }, }, ]); - // Start the request without awaiting to allow fake time control - const requestPromise = api.groups.get(); - const [response] = await Promise.allSettled([ - requestPromise, - // Advance fake time to trigger scheduled retry delay - // Pattern: request -> setTimeout for exponential backoff -> retry - fakeTime.nextAsync(), // Executes setTimeout (1s exponential backoff), triggering retry - ]); - if (response.status === 'fulfilled') { - expect(response.value.status).toBe(200); - } else { - throw new Error( - `TEST SETUP ERROR: Expected request to succeed for 500 server error retry test, but it was rejected. ` + - `This likely indicates a problem with the test setup (mock expectations, fake time, etc.). ` + - `Original error: ${response.reason}`, - ); - } + // Should not throw despite callback error + const response = await api.groups.get(); + expect(response.status).toBe(200); mockHttpClient.verifyAllRequestsMade(); mockLogger.verifyAllLogsLogged(); }); - }); - await t.step('Max Retries Exceeded', async () => { - return await withFakeTime(async (fakeTime) => { + await t.step('Token Refresh Failure Scenarios', async () => { const api = new XmApi({ hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', + accessToken: 'expired-token', + refreshToken: 'invalid-refresh-token', + clientId: 'test-client-id', httpClient: mockHttpClient, logger: mockLogger, - maxRetries: 1, }); mockHttpClient.setReqRes([ - // First request fails with 500 server error + // First request fails with 401 { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Authorization': 'Bearer expired-token', 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, mockedResponse: { - status: 500, + status: 401, headers: { 'Content-Type': 'application/json' }, - body: { reason: 'Internal Server Error' }, + body: { error: 'Token expired' }, }, }, - // Retry also fails with 500, exhausting maxRetries (1) + // Token refresh request fails { expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', }, + body: + 'grant_type=refresh_token&refresh_token=invalid-refresh-token&client_id=test-client-id', }, mockedResponse: { - status: 500, + status: 401, headers: { 'Content-Type': 'application/json' }, - body: { reason: 'Internal Server Error' }, + // Real error structure from xMatters API (verified via sandbox testing) + body: { code: 401, message: 'Invalid refresh token', reason: 'Unauthorized' }, }, }, ]); - // Start the request without awaiting to allow fake time control - const requestPromise = api.groups.get(); - const [result] = await Promise.allSettled([ - requestPromise, - // Advance fake time to process retry attempts until max retries exceeded - // Pattern: request -> setTimeout for exponential backoff -> retry -> throw error - fakeTime.nextAsync(), // Executes setTimeout (1s exponential backoff), triggering retry that also fails - ]); - if (result.status === 'rejected') { - const error = result.reason; - expect(error).toBeInstanceOf(XmApiError); + try { + await api.groups.get(); + } catch (error) { const apiError = error as XmApiError; - // Should throw with the extracted error message from the response - expect(apiError.message).toBe('Internal Server Error'); - expect(apiError.response?.status).toBe(500); - } else { - throw new Error( - `TEST SETUP ERROR: Expected request to fail after max retries exceeded, but it succeeded. ` + - `This likely indicates a problem with the test setup (mock expectations, fake time, etc.). ` + - `Actual response: ${JSON.stringify(result.value)}`, - ); + expect(apiError).toBeInstanceOf(XmApiError); + expect(apiError.message).toBe('Failed to refresh token'); + expect(apiError.response?.status).toBe(401); + expect(apiError.response?.body).toEqual({ + code: 401, + message: 'Invalid refresh token', + reason: 'Unauthorized', + }); + } finally { + mockHttpClient.verifyAllRequestsMade(); } - mockHttpClient.verifyAllRequestsMade(); }); - }); - await t.step('HTTP Error Response Structure', async () => { - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, - }); - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups/nonexistent', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + await t.step('OAuth Token Acquisition', async () => { + let tokenRefreshCalled = false; + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + onTokenRefresh: (accessToken, refreshToken) => { + tokenRefreshCalled = true; + expect(accessToken).toBe('obtained-access-token'); + expect(refreshToken).toBe('obtained-refresh-token'); }, - }, - mockedResponse: { - status: 404, - headers: { 'Content-Type': 'application/json' }, - body: { error: 'Group not found', code: 'GROUP_NOT_FOUND' }, - }, - }]); - // Test HTTP error response handling (server responds with 404 status) - // This tests a different scenario than network errors - here the server successfully - // responds but with an error status code, so XmApiError should contain response details - try { - await api.groups.getByIdentifier('nonexistent'); - } catch (error) { - const apiError = error as XmApiError; - expect(apiError).toBeInstanceOf(XmApiError); - expect(apiError.response).toBeDefined(); - expect(apiError.response?.status).toBe(404); - expect(apiError.response?.body).toEqual({ - error: 'Group not found', - code: 'GROUP_NOT_FOUND', }); - expect(apiError.response?.headers).toEqual({ 'Content-Type': 'application/json' }); - } finally { - mockHttpClient.verifyAllRequestsMade(); - } - }); - - await t.step('Network Error Handling', async () => { - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, - }); - // Use mockedError to simulate network connection failure - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + // We'll validate the URL params more flexibly since URLSearchParams order can vary + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + body: 'grant_type=password&client_id=test-client&username=testuser&password=testpass', + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { + access_token: 'obtained-access-token', + refresh_token: 'obtained-refresh-token', + token_type: 'bearer', + expires_in: 3600, + }, }, - }, - mockedError: new Error('Network connection failed'), - }]); - // MockHttpClient with mockedError will always reject, so we can test error handling directly - try { - await api.groups.get(); - } catch (error) { - const apiError = error as XmApiError; - expect(apiError).toBeInstanceOf(XmApiError); - expect(apiError.message).toBe('Request failed'); - expect(apiError.response).toBeNull(); - expect((apiError.cause as Error)?.message).toBe('Network connection failed'); - } finally { + }]); + const response = await api.oauth.obtainTokens({ clientId: 'test-client' }); + expect(response.status).toBe(200); + expect(response.body.access_token).toBe('obtained-access-token'); + expect(tokenRefreshCalled).toBe(true); + // Validate that the request body contains the expected parameters + const request = mockHttpClient.requests[0]; + const bodyString = request.body as string; + expect(bodyString).toContain('grant_type=password'); + expect(bodyString).toContain('username=testuser'); + expect(bodyString).toContain('password=testpass'); + expect(bodyString).toContain('client_id=test-client'); mockHttpClient.verifyAllRequestsMade(); - } + }); }); - await t.step('Custom Headers Integration', async () => { - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, - defaultHeaders: { - 'X-Custom-Header': 'custom-value', - 'X-Client-Version': '1.0.0', - }, - }); - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + await t.step('HTTP Client & Request Handling', async (t) => { + await t.step('Custom Headers Integration', async () => { + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + defaultHeaders: { 'X-Custom-Header': 'custom-value', 'X-Client-Version': '1.0.0', }, - }, - mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: { count: 0, total: 0, data: [] }, - }, - }]); - const response = await api.groups.get(); - expect(response.status).toBe(200); - mockHttpClient.verifyAllRequestsMade(); - }); - - await t.step('OAuth Token Acquisition', async () => { - let tokenRefreshCalled = false; - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, - onTokenRefresh: (accessToken, refreshToken) => { - tokenRefreshCalled = true; - expect(accessToken).toBe('obtained-access-token'); - expect(refreshToken).toBe('obtained-refresh-token'); - }, + }); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + 'X-Custom-Header': 'custom-value', + 'X-Client-Version': '1.0.0', + }, + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { count: 0, total: 0, data: [] }, + }, + }]); + const response = await api.groups.get(); + expect(response.status).toBe(200); + mockHttpClient.verifyAllRequestsMade(); }); - // We'll validate the URL params more flexibly since URLSearchParams order can vary - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'POST', - url: 'https://test.xmatters.com/api/xm/1/oauth2/token', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + + await t.step('User-Agent Header', async () => { + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + }); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', // Should match version in deno.json + }, }, - body: 'grant_type=password&client_id=test-client&username=testuser&password=testpass', - }, - mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: { - access_token: 'obtained-access-token', - refresh_token: 'obtained-refresh-token', - token_type: 'bearer', - expires_in: 3600, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { count: 0, total: 0, data: [] }, }, - }, - }]); - const response = await api.oauth.obtainTokens({ clientId: 'test-client' }); - expect(response.status).toBe(200); - expect(response.body.access_token).toBe('obtained-access-token'); - expect(tokenRefreshCalled).toBe(true); - // Validate that the request body contains the expected parameters - const request = mockHttpClient.requests[0]; - const bodyString = request.body as string; - expect(bodyString).toContain('grant_type=password'); - expect(bodyString).toContain('username=testuser'); - expect(bodyString).toContain('password=testpass'); - expect(bodyString).toContain('client_id=test-client'); - mockHttpClient.verifyAllRequestsMade(); + }]); + const response = await api.groups.get(); + expect(response.status).toBe(200); + mockHttpClient.verifyAllRequestsMade(); + }); }); - await t.step('User-Agent Header', async () => { - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, + await t.step('Retry Logic', async (t) => { + await t.step('Retry Logic for 429 Rate Limit', async () => { + return await withFakeTime(async (fakeTime) => { + mockLogger.setExpectedLogs([ + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 429 \(\d+ms\)$/ }, + { + level: 'debug', + message: 'Request failed with status 429, retrying in 1000ms (attempt 1/2)', + }, + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 429 \(\d+ms\)$/ }, + { + level: 'debug', + message: 'Request failed with status 429, retrying in 2000ms (attempt 2/2)', + }, + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, + ]); + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + maxRetries: 2, + }); + mockHttpClient.setReqRes([ + // First request fails with 429 rate limit + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 429, + headers: { 'Retry-After': '1' }, // Server requests 1 second delay + body: { error: 'Rate limit exceeded' }, + }, + }, + // First retry also fails with 429 rate limit + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 429, + headers: { 'Retry-After': '1' }, + body: { error: 'Rate limit exceeded' }, + }, + }, + // Second retry succeeds + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { count: 0, total: 0, data: [] }, + }, + }, + ]); + // Start the request without awaiting to allow fake time control + const requestPromise = api.groups.get(); + const [response] = await Promise.allSettled([ + requestPromise, + // Advance fake time to trigger scheduled retry delays + // Pattern: request -> setTimeout for retry delay -> retry -> setTimeout -> retry + fakeTime.nextAsync(), // Executes first setTimeout (1s delay), triggering first retry + fakeTime.nextAsync(), // Executes second setTimeout (1s delay), triggering second retry + ]); + if (response.status === 'fulfilled') { + expect(response.value.status).toBe(200); + } else { + throw new Error( + `TEST SETUP ERROR: Expected request to succeed for retry logic test, but it was rejected. ` + + `This likely indicates a problem with the test setup (mock expectations, fake time, etc.). ` + + `Original error: ${response.reason}`, + ); + } + mockHttpClient.verifyAllRequestsMade(); + mockLogger.verifyAllLogsLogged(); + }); }); - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', // Should match version in deno.json - }, - }, - mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: { count: 0, total: 0, data: [] }, - }, - }]); - const response = await api.groups.get(); - expect(response.status).toBe(200); - mockHttpClient.verifyAllRequestsMade(); - }); - await t.step('Logging Integration', async () => { - // Test that logging integration works correctly - validate basic request/response logs - mockLogger.setExpectedLogs([ - { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, - { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, - ]); - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, + await t.step('Retry Logic for 500 Server Error', async () => { + return await withFakeTime(async (fakeTime) => { + mockLogger.setExpectedLogs([ + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 500 \(\d+ms\)$/ }, + { + level: 'debug', + message: 'Request failed with status 500, retrying in 1000ms (attempt 1/1)', + }, + { level: 'debug', message: '--> GET https://test.xmatters.com/api/xm/1/groups' }, + { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, + ]); + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + maxRetries: 1, + }); + mockHttpClient.setReqRes([ + // First request fails with 500 server error + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 500, + headers: { 'Content-Type': 'application/json' }, + body: { error: 'Internal Server Error' }, + }, + }, + // Retry succeeds after exponential backoff delay + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { count: 0, total: 0, data: [] }, + }, + }, + ]); + // Start the request without awaiting to allow fake time control + const requestPromise = api.groups.get(); + const [response] = await Promise.allSettled([ + requestPromise, + // Advance fake time to trigger scheduled retry delay + // Pattern: request -> setTimeout for exponential backoff -> retry + fakeTime.nextAsync(), // Executes setTimeout (1s exponential backoff), triggering retry + ]); + if (response.status === 'fulfilled') { + expect(response.value.status).toBe(200); + } else { + throw new Error( + `TEST SETUP ERROR: Expected request to succeed for 500 server error retry test, but it was rejected. ` + + `This likely indicates a problem with the test setup (mock expectations, fake time, etc.). ` + + `Original error: ${response.reason}`, + ); + } + mockHttpClient.verifyAllRequestsMade(); + mockLogger.verifyAllLogsLogged(); + }); }); - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, - }, - mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: { count: 0, total: 0, data: [] }, - }, - }]); - await api.groups.get(); - mockHttpClient.verifyAllRequestsMade(); - mockLogger.verifyAllLogsLogged(); - }); - await t.step('Non-JSON Response Body Handling', async () => { - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, + await t.step('Max Retries Exceeded', async () => { + return await withFakeTime(async (fakeTime) => { + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + maxRetries: 1, + }); + mockHttpClient.setReqRes([ + // First request fails with 500 server error + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 500, + headers: { 'Content-Type': 'application/json' }, + body: { reason: 'Internal Server Error' }, + }, + }, + // Retry also fails with 500, exhausting maxRetries (1) + { + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, + }, + mockedResponse: { + status: 500, + headers: { 'Content-Type': 'application/json' }, + body: { reason: 'Internal Server Error' }, + }, + }, + ]); + // Start the request without awaiting to allow fake time control + const requestPromise = api.groups.get(); + const [result] = await Promise.allSettled([ + requestPromise, + // Advance fake time to process retry attempts until max retries exceeded + // Pattern: request -> setTimeout for exponential backoff -> retry -> throw error + fakeTime.nextAsync(), // Executes setTimeout (1s exponential backoff), triggering retry that also fails + ]); + if (result.status === 'rejected') { + const error = result.reason; + expect(error).toBeInstanceOf(XmApiError); + const apiError = error as XmApiError; + // Should throw with the extracted error message from the response + expect(apiError.message).toBe('Internal Server Error'); + expect(apiError.response?.status).toBe(500); + } else { + throw new Error( + `TEST SETUP ERROR: Expected request to fail after max retries exceeded, but it succeeded. ` + + `This likely indicates a problem with the test setup (mock expectations, fake time, etc.). ` + + `Actual response: ${JSON.stringify(result.value)}`, + ); + } + mockHttpClient.verifyAllRequestsMade(); + }); }); - mockHttpClient.setReqRes([{ - expectedRequest: { - method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups/invalid', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, - }, - mockedResponse: { - status: 400, - headers: { 'Content-Type': 'text/plain' }, - body: 'Invalid request format', - }, - }]); - try { - await api.groups.getByIdentifier('invalid'); - } catch (error) { - const apiError = error as XmApiError; - expect(apiError).toBeInstanceOf(XmApiError); - expect(apiError.message).toBe('Invalid request format'); - expect(apiError.response?.status).toBe(400); - expect(apiError.response?.body).toBe('Invalid request format'); - } finally { - mockHttpClient.verifyAllRequestsMade(); - } }); - await t.step('Token Refresh Failure Scenarios', async () => { - const api = new XmApi({ - hostname: 'test.xmatters.com', - accessToken: 'expired-token', - refreshToken: 'invalid-refresh-token', - clientId: 'test-client-id', - httpClient: mockHttpClient, - logger: mockLogger, - }); - mockHttpClient.setReqRes([ - // First request fails with 401 - { + await t.step('Error Handling', async (t) => { + await t.step('HTTP Error Response Structure', async () => { + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + }); + mockHttpClient.setReqRes([{ expectedRequest: { method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups', + url: 'https://test.xmatters.com/api/xm/1/groups/nonexistent', headers: { - 'Authorization': 'Bearer expired-token', + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, mockedResponse: { - status: 401, + status: 404, headers: { 'Content-Type': 'application/json' }, - body: { error: 'Token expired' }, + body: { error: 'Group not found', code: 'GROUP_NOT_FOUND' }, + }, + }]); + // Test HTTP error response handling (server responds with 404 status) + // This tests a different scenario than network errors - here the server successfully + // responds but with an error status code, so XmApiError should contain response details + try { + await api.groups.getByIdentifier('nonexistent'); + } catch (error) { + const apiError = error as XmApiError; + expect(apiError).toBeInstanceOf(XmApiError); + expect(apiError.response).toBeDefined(); + expect(apiError.response?.status).toBe(404); + expect(apiError.response?.body).toEqual({ + error: 'Group not found', + code: 'GROUP_NOT_FOUND', + }); + expect(apiError.response?.headers).toEqual({ 'Content-Type': 'application/json' }); + } finally { + mockHttpClient.verifyAllRequestsMade(); + } + }); + + await t.step('Network Error Handling', async () => { + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + }); + // Use mockedError to simulate network connection failure + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + }, }, - }, - // Token refresh request fails - { + mockedError: new Error('Network connection failed'), + }]); + // MockHttpClient with mockedError will always reject, so we can test error handling directly + try { + await api.groups.get(); + } catch (error) { + const apiError = error as XmApiError; + expect(apiError).toBeInstanceOf(XmApiError); + expect(apiError.message).toBe('Request failed'); + expect(apiError.response).toBeNull(); + expect((apiError.cause as Error)?.message).toBe('Network connection failed'); + } finally { + mockHttpClient.verifyAllRequestsMade(); + } + }); + + await t.step('Non-JSON Response Body Handling', async () => { + const api = new XmApi({ + hostname: 'test.xmatters.com', + username: 'testuser', + password: 'testpass', + httpClient: mockHttpClient, + logger: mockLogger, + }); + mockHttpClient.setReqRes([{ expectedRequest: { - method: 'POST', - url: 'https://test.xmatters.com/api/xm/1/oauth2/token', + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups/invalid', headers: { - 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', + 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', }, - body: - 'grant_type=refresh_token&refresh_token=invalid-refresh-token&client_id=test-client-id', }, mockedResponse: { - status: 401, - headers: { 'Content-Type': 'application/json' }, - // Real error structure from xMatters API (verified via sandbox testing) - body: { code: 401, message: 'Invalid refresh token', reason: 'Unauthorized' }, - }, - }, - ]); - try { - await api.groups.get(); - } catch (error) { - const apiError = error as XmApiError; - expect(apiError).toBeInstanceOf(XmApiError); - expect(apiError.message).toBe('Failed to refresh token'); - expect(apiError.response?.status).toBe(401); - expect(apiError.response?.body).toEqual({ - code: 401, - message: 'Invalid refresh token', - reason: 'Unauthorized', - }); - } finally { - mockHttpClient.verifyAllRequestsMade(); - } + status: 400, + headers: { 'Content-Type': 'text/plain' }, + body: 'Invalid request format', + }, + }]); + try { + await api.groups.getByIdentifier('invalid'); + } catch (error) { + const apiError = error as XmApiError; + expect(apiError).toBeInstanceOf(XmApiError); + expect(apiError.message).toBe('Invalid request format'); + expect(apiError.response?.status).toBe(400); + expect(apiError.response?.body).toBe('Invalid request format'); + } finally { + mockHttpClient.verifyAllRequestsMade(); + } + }); }); /* From 4b4b6f5aefdf6fab1b168da409d985a494c7bd83 Mon Sep 17 00:00:00 2001 From: johan Date: Fri, 27 Jun 2025 20:09:24 -0700 Subject: [PATCH 074/101] Continue refactoring unit tests --- .github/copilot-instructions.md | 186 +---- sandbox/validate-docs.ts | 1 - src/core/test-utils.ts | 36 + src/endpoints/groups/index.test.ts | 1025 ++++++++++------------------ src/endpoints/groups/types.ts | 57 +- 5 files changed, 441 insertions(+), 864 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8408c78..f4013ed 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -3,7 +3,7 @@ consume the xMatters API (AKA xmApi). The priorities for this project are: -1. **Consistency**: Both from the library developer's perspective and the library consumer's +1. **Consistency**: Both from the library maintainers' perspective and the library consumers' perspective, code should be consistent in style, structure, and behavior. This includes consistent naming conventions, error handling, and response structures. 2. **Zero Dependencies**: The library should not depend on any other libraries, except for Deno's @@ -11,7 +11,8 @@ The priorities for this project are: 3. **Dependency Injection**: The consumer should be able to inject their own HTTP client, logger, and other dependencies. 4. **Type Safety**: The library should be fully type-safe, leveraging TypeScript's capabilities to - ensure that consumers get the best developer experience. + ensure that consumers get the best developer experience. However, use TypeScript features when + they add value, not just because you can. 5. **Documentation**: The library should be well-documented, with clear examples and usage instructions. @@ -23,189 +24,18 @@ For iterative development, you can make use of the /sandbox/index.ts file to tes file is not part of the library and is meant for quick prototyping and testing. Do not modify the sandbox unless explicitly instructed to do so. -The following code snippet, highly imperfect as it is, serves as _inspiration_ for some aspects of -the dependency injections. +To run the sandbox, always prompt me to run the following command: -It contains some good ideas, but certainly has many shortcomings that we should avoid in our -implementation. - -```javascript -const nodePath = require('path'); - -const urlBuilder = (baseUrl) => ({ url = '', pathParams = [], queryParams = {} } = {}) => { - let finalUrl; - try { - // check if consumer intends to pass a full url (thereby ignoring baseUrl for this request) - const reqUrl = new URL(url); - finalUrl = new URL(nodePath.join(reqUrl.href, ...pathParams)); - } catch (e) { - // check if consumer passed a url without a protocol (e.g. 'google.com') - if (/\.([a-z]{2,})$/i.test(url)) { - console.warn( - "Did you intend to pass a full url? Make sure to include 'http://' or 'https://'.", - ); - } - // if consumer did not pass a full url, use baseUrl and assume url is a relative path - const base = new URL(baseUrl || 'about:blank'); - finalUrl = new URL(nodePath.join(base.href, url, ...pathParams)); - } - Object.entries(queryParams).forEach(([key, value]) => finalUrl.searchParams.set(key, value)); - return finalUrl.toString(); -}; - -const headersBuilder = ( - setDefaultHeaders, - setFixedHeaders, -) => -({ headers } = {}) => { - // Allow setting headers as null to send headerless requests - if (setDefaultHeaders && headers === undefined) { - headers = setDefaultHeaders(); - } - if (setFixedHeaders) { - if (headers) { - // Allow overriding fixedHeaders on a per-request basis - headers = { ...setFixedHeaders(), ...headers }; - // TODO: think some more about this. There may or may not be value in pushing consumers - // to pass a setDefaultHeaders - } else if (headers === undefined) { - headers = setFixedHeaders(); - } - } - return headers; -}; - -const reqBuilder = (buildUrl, buildHeaders) => (reqElements = {}) => { - const req = { - method: reqElements.method ? reqElements.method.toUpperCase() : 'GET', - url: buildUrl(reqElements), - headers: buildHeaders(reqElements), - attemptNumber: reqElements.attemptNumber || 1, - }; - if (reqElements.data) { - const { data } = reqElements; - req.data = typeof data === 'string' ? data : JSON.stringify(data); - } - return req; -}; - -const getBuilder = ({ - requestAdapter, - responseAdapter, - errorAdapter, -} = {}) => -({ - baseUrl, - successHandler, - failureHandler, - setDefaultHeaders, - setFixedHeaders, -} = {}) => { - const buildUrl = urlBuilder(baseUrl); - const buildHeaders = headersBuilder(setDefaultHeaders, setFixedHeaders); - const buildReq = reqBuilder(buildUrl, buildHeaders); - const send = (reqElements) => { - const req = buildReq(reqElements); - return requestAdapter(req) - .then(responseAdapter) - .catch(errorAdapter) - .then((res) => successHandler ? successHandler(res, req) : res) - .catch((err) => { - if (failureHandler) { - return failureHandler(err, req); - } - throw err; - }); - }; - return { - send, - get: (url, { queryParams, pathParams, headers } = {}) => - send({ url, queryParams, pathParams, headers }), - post: (url, data, { pathParams, headers } = {}) => - send({ method: 'post', url, pathParams, headers, data }), - put: (url, data, { pathParams, headers } = {}) => - send({ method: 'put', url, pathParams, headers, data }), - patch: (url, data, { pathParams, headers } = {}) => - send({ method: 'patch', url, pathParams, headers, data }), - delete: (url, { pathParams, headers } = {}) => - send({ method: 'delete', url, pathParams, headers }), - options: (url, { pathParams, headers } = {}) => - send({ method: 'options', url, pathParams, headers }), - }; -}; - -module.exports = getBuilder; +```bash +deno task sandbox ``` -Features: - -- http client injection with adapters for request, response, and error handling -- use fetch as the default HTTP client (but even then fetch should be injectable and come with - adapters) -- logger injection -- use console as the default logger -- when a request fails with a response, there should be retry logic that: - - retries the request up to a configurable maximum number of attempts - - if it's a 401 Unauthorized error, it should retry the request after refreshing the access token -- perfectly consistant errors for the consumer - - the error should be a subclass of Error as to always have a message property populated - - the error should have a response property that is either undefined or an object with the - following properties: - - body: the response body as a string - - status: the HTTP status code of the response - - headers: an object containing the response headers - - Because consumers can inject their own HTTP client: - - the error should not contain any information about the HTTP client used - - the consumers should be able to specify when the error is thrown. Most likely in their http - client adapters. For eg: the Slack API almost never responds with a 4xx or 5xx status code, so - the consumer should be able to specify that the error should be thrown on 200 status code when - the response body contains the `ok` property set to `false`. -- For maintainers: - - the code should be easy to extend with new endpoints - - each new endpoint should have access to the same request building logic (e.g. url building, - headers building, etc. and reusable request sending logic via get, post, put, patch, delete - methods) - - We should make it easy for maintainers not to have to repeat themselves when adding new - endpoints - - for example, if adding a new /people endpoint, which would have methods such as - `getDevices`, the maintainers should not have to specify "/people/devices" when "/devices" - would suffice and the library should automatically prepend "/people" to the path - - the code should be easy to test - - the unit test should not send actual HTTP requests over the network - - the unit test should be based around something such as - "expect(sendRequest).toHaveBeenCalledWith(httpRequest)" so that the test is not dependent on - the actual HTTP client used - - each endpoint will be defined in its own directory inside the `endpoints` directory - - `types.ts` file that exports the endpoint's types - - `index.ts` file that exports the endpoint's methods - - `index.test.ts` file that contains the unit tests for the endpoint -- For consumers: - - they should always be able to provide a params object which is to be used to build a query - string - - if maintainers have relied on a params object to implement and endpoint, when consumers - provide a params object it should be merged with the default params for that endpoint - - it should be possible for the maintainers to make some of the params overrideable by the - consumer, while others should be fixed - - for example, if the endpoint is `/people/{id}/devices`, the `id` param should be fixed and - not overrideable by the consumer, while the `type` param should be overrideable by the - consumer - - they should be able to provide a headers object: - - as a config option to specify default headers for all requests - - as a per-request option to override the default headers - - the headers should be merged with the default headers, with the per-request headers taking - precedence - - If headers are not provided, the default headers should be used, but if they're explicitly - set to `null`, no headers should be sent - - they should be able to provide a function that will be called when oauth tokens get refreshed - - this function should be called with the new access token and the refresh token as arguments - - the function should only be called if it was provided by the consumer - - the function should be wrapped in a try-catch block to ensure that any errors thrown by the - function do not crash the library +Unlike the sandbox, unit tests should not send actual HTTP requests over the network, ever. Do not make any changes until you have 95% confidence that you know what to build. Ask me follow up questions until you have that confidence. -To run unit tests, always prompt the user to run the following command: +To run unit tests, always prompt me to run the following command: ```bash deno test diff --git a/sandbox/validate-docs.ts b/sandbox/validate-docs.ts index dd00358..8f0d9f6 100644 --- a/sandbox/validate-docs.ts +++ b/sandbox/validate-docs.ts @@ -420,7 +420,6 @@ async function validateEdgeCases() { try { await xm.groups.get({ query: { - // @ts-expect-error - Testing invalid value sortBy: 'INVALID_SORT_FIELD', limit: 1, }, diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts index 29a21af..3c3b97f 100644 --- a/src/core/test-utils.ts +++ b/src/core/test-utils.ts @@ -184,3 +184,39 @@ export async function withFakeTime(testFn: (fakeTime: FakeTime) => Promise fakeTime.restore(); } } + +/** + * Reusable test constants for endpoint testing + */ +export const TestConstants = { + /** Standard Basic Auth test configuration for creating RequestHandler instances */ + BASIC_CONFIG: { + hostname: 'https://test.xmatters.com', + username: 'testuser', + password: 'testpass', + } as const, + + /** Default headers used in Basic Auth test requests */ + BASIC_AUTH_HEADERS: { + 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', // base64 of testuser:testpass + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + } as const, + + /** Standard OAuth test configuration for creating RequestHandler instances */ + OAUTH_CONFIG: { + hostname: 'https://test.xmatters.com', + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + clientId: 'test-client-id', + } as const, + + /** Default headers used in OAuth test requests */ + OAUTH_HEADERS: { + 'Authorization': 'Bearer test-access-token', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', + } as const, +} as const; diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index fdbe734..5590206 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -1,724 +1,397 @@ -/** - * Comprehensive test suite for GroupsEndpoint using Deno's standard testing library. - * - * Testing Philosophy: - * - Only mock the HttpClient to prevent actual network calls - * - All other library code runs real implementation - * - Verify exact HTTP requests sent by inspecting stub call arguments - * - Test all endpoint methods - * - Test both Basic Auth and OAuth authentication - * - Test error scenarios (HTTP errors and network failures) - * - Test parameter handling and URL construction - * - Test different configuration options (hostname, auth methods) - * - * This approach ensures: - * - High confidence that real library code works correctly - * - Fast test execution (no network I/O) - * - Clear verification of what HTTP requests are actually sent - * - Easy maintenance as it focuses on the interface contract - */ - -import { expect } from 'std/expect/mod.ts'; -import { stub } from 'std/testing/mock.ts'; -import { FakeTime } from 'std/testing/time.ts'; - import { GroupsEndpoint } from './index.ts'; import { RequestHandler } from '../../core/request-handler.ts'; -import type { HttpClient, HttpRequest } from '../../core/types/internal/http.ts'; -import type { Logger, XmApiConfig } from '../../core/types/internal/config.ts'; -import type { Group } from './types.ts'; -import { XmApiError } from '../../core/errors.ts'; - -// Test helper to create mock setup -function createEndpointTestSetup(options: { - hostname?: string; - username?: string; - password?: string; - accessToken?: string; - refreshToken?: string; - clientId?: string; - maxRetries?: number; - onTokenRefresh?: (accessToken: string, refreshToken: string) => Promise; - expiredToken?: boolean; -} = {}) { - const { - hostname = 'https://example.xmatters.com', - username = 'test-user', - password = 'test-password', - accessToken, - refreshToken, - clientId, - maxRetries = 3, - onTokenRefresh, - } = options; - - // Create silent mock logger - const mockLogger: Logger = { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - }; +import { MockHttpClient, MockLogger, TestConstants } from '../../core/test-utils.ts'; - // Create auth options based on provided parameters - const mockHttpClient: HttpClient = { - send: () => Promise.resolve({ status: 200, headers: {}, body: {} }), - }; +// Shared test infrastructure - MockHttpClient auto-resets between tests +const mockHttpClient = new MockHttpClient(); +const mockLogger = new MockLogger(); - let mockConfig: XmApiConfig; - if (accessToken && refreshToken && clientId) { - // OAuth configuration - all three are required - mockConfig = { - hostname, - accessToken, - refreshToken, - clientId, - onTokenRefresh, - maxRetries, - httpClient: mockHttpClient, - logger: mockLogger, - }; - } else { - // Basic auth configuration - mockConfig = { - hostname, - username, - password, - onTokenRefresh, - maxRetries, - httpClient: mockHttpClient, - logger: mockLogger, - }; - } - - const requestHandler = new RequestHandler(mockConfig); - const endpoint = new GroupsEndpoint(requestHandler); +const requestHandler = new RequestHandler({ + httpClient: mockHttpClient, + logger: mockLogger, + ...TestConstants.BASIC_CONFIG, +}); - return { mockHttpClient, endpoint, mockLogger }; -} +const groups = new GroupsEndpoint(requestHandler); -// Mock data for tests -const mockGroup: Group = { - id: 'test-group-123', +const mockSingleGroupResponse = { + id: 'test-group-id', targetName: 'Test Group', recipientType: 'GROUP', status: 'ACTIVE', groupType: 'ON_CALL', - created: '2024-01-01T00:00:00Z', - description: 'Test group for unit tests', + created: '2024-01-01T00:00:00.000Z', }; -const mockGroupsList: Group[] = [ - mockGroup, - { - id: 'test-group-456', - targetName: 'Another Group', - recipientType: 'GROUP', - status: 'ACTIVE', - groupType: 'BROADCAST', - created: '2024-01-02T00:00:00Z', +const mockPaginatedGroupsResponse = { + count: 1, + total: 1, + data: [mockSingleGroupResponse], + links: { + self: '/api/xm/1/groups?limit=100&offset=0', }, -]; - -const mockPaginatedResponse = { - status: 200, - headers: { 'content-type': 'application/json' }, - body: { - count: 2, - total: 10, - data: mockGroupsList, - }, -}; - -const mockSingleGroupResponse = { - status: 200, - headers: { 'content-type': 'application/json' }, - body: mockGroup, -}; - -const mockEmptyResponse = { - status: 204, - headers: {}, - body: undefined, }; Deno.test('GroupsEndpoint', async (t) => { - await t.step('get() - sends correct HTTP request with no params', async () => { - const { mockHttpClient, endpoint } = createEndpointTestSetup(); - const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); - try { - const response = await endpoint.get(); - // Verify HTTP client was called exactly once - expect(sendStub.calls.length).toBe(1); - // Verify the request details - const sentRequest: HttpRequest = sendStub.calls[0].args[0]; - expect(sentRequest.method).toBe('GET'); - expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups'); - expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); - expect(sentRequest.headers?.['Accept']).toBe('application/json'); - expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); - expect(sentRequest.body).toBeUndefined(); - // Verify response is returned correctly - expect(response).toEqual(mockPaginatedResponse); - } finally { - sendStub.restore(); - } - }); - - await t.step('get() - sends correct HTTP request with pagination params', async () => { - const { mockHttpClient, endpoint } = createEndpointTestSetup(); - const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); - try { - const response = await endpoint.get({ query: { limit: 10, offset: 20 } }); - const sentRequest: HttpRequest = sendStub.calls[0].args[0]; - expect(sentRequest.method).toBe('GET'); - expect(sentRequest.url).toBe( - 'https://example.xmatters.com/api/xm/1/groups?limit=10&offset=20', - ); - expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); - expect(sentRequest.headers?.['Accept']).toBe('application/json'); - expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); - expect(sentRequest.body).toBeUndefined(); - expect(response).toEqual(mockPaginatedResponse); - } finally { - sendStub.restore(); - } - }); - - await t.step('get() - sends correct HTTP request with search params', async () => { - const { mockHttpClient, endpoint } = createEndpointTestSetup(); - const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); - try { - const response = await endpoint.get({ query: { search: 'oncall', limit: 5 } }); - const sentRequest: HttpRequest = sendStub.calls[0].args[0]; - expect(sentRequest.method).toBe('GET'); - expect(sentRequest.url).toBe( - 'https://example.xmatters.com/api/xm/1/groups?search=oncall&limit=5', - ); - expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); - expect(sentRequest.headers?.['Accept']).toBe('application/json'); - expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); - expect(sentRequest.body).toBeUndefined(); - expect(response).toEqual(mockPaginatedResponse); - } finally { - sendStub.restore(); - } - }); - - await t.step('getByIdentifier() - sends correct HTTP request', async () => { - const { mockHttpClient, endpoint } = createEndpointTestSetup(); - const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockSingleGroupResponse)); - try { - const response = await endpoint.getByIdentifier('test-group-123'); - expect(sendStub.calls.length).toBe(1); - const sentRequest: HttpRequest = sendStub.calls[0].args[0]; - expect(sentRequest.method).toBe('GET'); - expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups/test-group-123'); - expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); - expect(sentRequest.headers?.['Accept']).toBe('application/json'); - expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); - expect(sentRequest.body).toBeUndefined(); - expect(response).toEqual(mockSingleGroupResponse); - } finally { - sendStub.restore(); - } - }); - - await t.step('save() - sends correct HTTP request for creating group', async () => { - const { mockHttpClient, endpoint } = createEndpointTestSetup(); - const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockSingleGroupResponse)); - const newGroup = { - targetName: 'New Group', - groupType: 'BROADCAST' as const, - description: 'A new test group', - }; - try { - const response = await endpoint.save(newGroup); - expect(sendStub.calls.length).toBe(1); - const sentRequest: HttpRequest = sendStub.calls[0].args[0]; - expect(sentRequest.method).toBe('POST'); - expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups'); - expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); - expect(sentRequest.headers?.['Accept']).toBe('application/json'); - expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); - expect(sentRequest.body).toEqual(newGroup); - expect(response).toEqual(mockSingleGroupResponse); - } finally { - sendStub.restore(); - } - }); - - await t.step('delete() - sends correct HTTP request', async () => { - const { mockHttpClient, endpoint } = createEndpointTestSetup(); - const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockEmptyResponse)); - try { - const response = await endpoint.delete('test-group-123'); - expect(sendStub.calls.length).toBe(1); - const sentRequest: HttpRequest = sendStub.calls[0].args[0]; - expect(sentRequest.method).toBe('DELETE'); - expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups/test-group-123'); - expect(sentRequest.body).toBeUndefined(); - expect(response).toEqual(mockEmptyResponse); - } finally { - sendStub.restore(); - } - }); + await t.step('get() - List Groups', async (t) => { + await t.step('makes GET request without parameters', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: TestConstants.BASIC_AUTH_HEADERS, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockPaginatedGroupsResponse, + }, + }]); + await groups.get(); + }); - await t.step('OAuth authentication - sends correct Authorization header', async () => { - const { mockHttpClient, endpoint } = createEndpointTestSetup({ - accessToken: 'test-access-token', - refreshToken: 'test-refresh-token', - clientId: 'test-client-id', - hostname: 'https://oauth.xmatters.com', + await t.step('makes GET request with query parameters', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups?limit=10&status=ACTIVE', + headers: TestConstants.BASIC_AUTH_HEADERS, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockPaginatedGroupsResponse, + }, + }]); + await groups.get({ + query: { + limit: 10, + status: 'ACTIVE', + }, + }); }); - const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); - try { - const response = await endpoint.get(); - expect(sendStub.calls.length).toBe(1); - const sentRequest: HttpRequest = sendStub.calls[0].args[0]; - expect(sentRequest.method).toBe('GET'); - expect(sentRequest.url).toBe('https://oauth.xmatters.com/api/xm/1/groups'); - expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); - expect(sentRequest.headers?.['Accept']).toBe('application/json'); - expect(sentRequest.headers?.['Authorization']).toBe('Bearer test-access-token'); - expect(sentRequest.body).toBeUndefined(); - expect(response).toEqual(mockPaginatedResponse); - } finally { - sendStub.restore(); - } - }); - await t.step('Error handling - throws XmApiError on HTTP error', async () => { - const { mockHttpClient, endpoint } = createEndpointTestSetup(); - const errorResponse = { - status: 404, - headers: { 'content-type': 'application/json' }, - body: { message: 'Group not found' }, - }; - const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(errorResponse)); - try { - let thrownError: unknown; - try { - await endpoint.getByIdentifier('non-existent-group'); - } catch (error) { - thrownError = error; - } - expect(thrownError).toBeInstanceOf(XmApiError); - const xmError = thrownError as XmApiError; - expect(xmError.message).toBe('Group not found'); // Uses the message from response body - expect(xmError.response?.status).toBe(404); - expect(xmError.response?.body).toEqual({ message: 'Group not found' }); - } finally { - sendStub.restore(); - } - }); + await t.step('makes GET request with complex query parameters', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: + 'https://test.xmatters.com/api/xm/1/groups?search=admin+database&operand=AND&groupType=ON_CALL&embed=supervisors%2Cobservers&fields=NAME&sortBy=NAME&sortOrder=ASCENDING', + headers: TestConstants.BASIC_AUTH_HEADERS, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockPaginatedGroupsResponse, + }, + }]); + await groups.get({ + query: { + search: 'admin database', + operand: 'AND', + groupType: 'ON_CALL', + embed: ['supervisors', 'observers'], + fields: 'NAME', + sortBy: 'NAME', + sortOrder: 'ASCENDING', + }, + }); + }); - await t.step('Error handling - throws XmApiError on network error', async () => { - const { mockHttpClient, endpoint } = createEndpointTestSetup(); - const networkError = new Error('Network connection failed'); - const sendStub = stub(mockHttpClient, 'send', () => Promise.reject(networkError)); - try { - let thrownError: unknown; - try { - await endpoint.get(); - } catch (error) { - thrownError = error; - } - expect(thrownError).toBeInstanceOf(XmApiError); - const xmError = thrownError as XmApiError; - expect(xmError.message).toBe('Request failed'); // Generic message for network errors - expect(xmError.response).toBeNull(); // No response for network errors - } finally { - sendStub.restore(); - } - }); + await t.step('makes GET request with custom headers', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + ...TestConstants.BASIC_AUTH_HEADERS, + 'X-Custom-Header': 'custom-value', + }, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockPaginatedGroupsResponse, + }, + }]); + await groups.get({ + headers: { + 'X-Custom-Header': 'custom-value', + }, + }); + }); - await t.step('Custom hostname - uses correct base URL', async () => { - const { mockHttpClient, endpoint } = createEndpointTestSetup({ - hostname: 'https://custom.xmatters.com', + await t.step('makes GET request with array parameters', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: + 'https://test.xmatters.com/api/xm/1/groups?members=user1%2Cuser2&sites=site1%2Csite2&supervisors=super1%2Csuper2', + headers: TestConstants.BASIC_AUTH_HEADERS, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockPaginatedGroupsResponse, + }, + }]); + await groups.get({ + query: { + members: ['user1', 'user2'], + sites: ['site1', 'site2'], + supervisors: ['super1', 'super2'], + }, + }); }); - const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); - try { - const response = await endpoint.get(); - expect(sendStub.calls.length).toBe(1); - const sentRequest: HttpRequest = sendStub.calls[0].args[0]; - expect(sentRequest.method).toBe('GET'); - expect(sentRequest.url).toBe('https://custom.xmatters.com/api/xm/1/groups'); - expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); - expect(sentRequest.headers?.['Accept']).toBe('application/json'); - expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); - expect(sentRequest.body).toBeUndefined(); - expect(response).toEqual(mockPaginatedResponse); - } finally { - sendStub.restore(); - } }); - await t.step('Basic auth - sends correct Authorization header', async () => { - const { mockHttpClient, endpoint } = createEndpointTestSetup({ - username: 'testuser', - password: 'testpass', + await t.step('getByIdentifier() - Get Single Group', async (t) => { + await t.step('makes GET request with ID', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups/test-group-id', + headers: TestConstants.BASIC_AUTH_HEADERS, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockSingleGroupResponse, + }, + }]); + await groups.getByIdentifier('test-group-id'); }); - const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); - try { - const response = await endpoint.get(); - expect(sendStub.calls.length).toBe(1); - const sentRequest: HttpRequest = sendStub.calls[0].args[0]; - expect(sentRequest.method).toBe('GET'); - expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups'); - expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); - expect(sentRequest.headers?.['Accept']).toBe('application/json'); - // Verify the basic auth header - expect(sentRequest.headers?.['Authorization']).toBeDefined(); - expect(sentRequest.headers?.['Authorization']).toBe('Basic ' + btoa('testuser:testpass')); - expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); - // Verify the basic auth encoding - const authPart = sentRequest.headers?.['Authorization']?.split(' ')[1]; - const decoded = atob(authPart!); - expect(decoded).toBe('testuser:testpass'); - expect(sentRequest.body).toBeUndefined(); - expect(response).toEqual(mockPaginatedResponse); - } finally { - sendStub.restore(); - } - }); - await t.step('save() with full group object - sends all fields correctly', async () => { - const { mockHttpClient, endpoint } = createEndpointTestSetup(); - const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockSingleGroupResponse)); - const fullGroup: Partial = { - id: 'existing-group-123', - targetName: 'Updated Group', - recipientType: 'GROUP', - status: 'ACTIVE', - groupType: 'ON_CALL', - description: 'Updated test group', - supervisors: ['user1', 'user2'], - }; - try { - const response = await endpoint.save(fullGroup); - expect(sendStub.calls.length).toBe(1); - const sentRequest: HttpRequest = sendStub.calls[0].args[0]; - expect(sentRequest.method).toBe('POST'); - expect(sentRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups'); - expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); - expect(sentRequest.headers?.['Accept']).toBe('application/json'); - expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); - expect(sentRequest.body).toEqual(fullGroup); - expect(response).toEqual(mockSingleGroupResponse); - } finally { - sendStub.restore(); - } - }); + await t.step('makes GET request with URL-encoded targetName', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups/Oracle%20Administrators', + headers: TestConstants.BASIC_AUTH_HEADERS, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockSingleGroupResponse, + }, + }]); + await groups.getByIdentifier('Oracle%20Administrators'); + }); - await t.step('get() with all possible parameters', async () => { - const { mockHttpClient, endpoint } = createEndpointTestSetup(); - const sendStub = stub(mockHttpClient, 'send', () => Promise.resolve(mockPaginatedResponse)); - try { - const response = await endpoint.get({ + await t.step('makes GET request with embed parameters', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: + 'https://test.xmatters.com/api/xm/1/groups/test-group-id?embed=supervisors%2Cservices', + headers: TestConstants.BASIC_AUTH_HEADERS, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockSingleGroupResponse, + }, + }]); + await groups.getByIdentifier('test-group-id', { query: { - limit: 25, - offset: 50, - search: 'test search', - // Add other params that might exist in GetGroupsParams + embed: ['supervisors', 'services'], }, }); - expect(sendStub.calls.length).toBe(1); - const sentRequest: HttpRequest = sendStub.calls[0].args[0]; - expect(sentRequest.method).toBe('GET'); - expect(sentRequest.url).toBe( - 'https://example.xmatters.com/api/xm/1/groups?limit=25&offset=50&search=test+search', - ); - expect(sentRequest.headers?.['Content-Type']).toBe('application/json'); - expect(sentRequest.headers?.['Accept']).toBe('application/json'); - expect(sentRequest.headers?.['Authorization']?.startsWith('Basic ')).toBe(true); - expect(sentRequest.body).toBeUndefined(); - expect(response).toEqual(mockPaginatedResponse); - } finally { - sendStub.restore(); - } - }); + }); - await t.step('retries on rate limit with Retry-After', async () => { - const fakeTime = new FakeTime(); - try { - const { mockHttpClient, mockLogger, endpoint } = createEndpointTestSetup(); - const rateLimitResponse = { - status: 429, - headers: { 'retry-after': '2', 'content-type': 'application/json' }, - body: { message: 'Too many requests' }, - }; - const successResponse = { - status: 200, - headers: { 'content-type': 'application/json' }, - body: { count: 2, total: 10, data: mockGroupsList }, - }; - let callCount = 0; - const sendStub = stub(mockHttpClient, 'send', () => { - callCount++; - return callCount === 1 - ? Promise.resolve(rateLimitResponse) - : Promise.resolve(successResponse); + await t.step('makes GET request with custom headers', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/groups/test-group-id', + headers: { + ...TestConstants.BASIC_AUTH_HEADERS, + 'X-Custom-Header': 'custom-value', + }, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockSingleGroupResponse, + }, + }]); + await groups.getByIdentifier('test-group-id', { + headers: { + 'X-Custom-Header': 'custom-value', + }, }); - const debugStub = stub(mockLogger, 'debug', () => {}); - try { - // Start the async request but DON'T await it yet - // This begins the async chain but allows us to control timing with FakeTime - const requestPromise = endpoint.get(); - - // Allow the first request to complete and set up the timer - // This advances fake time to let the setTimeout callback fire - await fakeTime.nextAsync(); - - // Verify the first request completed and retry was triggered - // At this point: initial request failed → setTimeout set → timeout fired → retry executed - expect(sendStub.calls.length).toBe(2); - expect(sendStub.calls[0].args[0].method).toBe('GET'); - expect(sendStub.calls[0].args[0].url).toBe('https://example.xmatters.com/api/xm/1/groups'); - expect(sendStub.calls[1].args[0].method).toBe('GET'); - expect(sendStub.calls[1].args[0].url).toBe('https://example.xmatters.com/api/xm/1/groups'); - - // Now advance time to trigger any additional timers (should be none) - await fakeTime.nextAsync(); - - // Finally await the original promise to get the result - // By now all async operations have completed thanks to our time control - const response = await requestPromise; - expect(response.body).toEqual(successResponse.body); - expect(sendStub.calls.length).toBe(2); - // Verify both calls were GET requests to /groups - const firstRequest: HttpRequest = sendStub.calls[0].args[0]; - expect(firstRequest.method).toBe('GET'); - expect(firstRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups'); - const retryRequest: HttpRequest = sendStub.calls[1].args[0]; - expect(retryRequest.method).toBe('GET'); - expect(retryRequest.url).toBe('https://example.xmatters.com/api/xm/1/groups'); - // Should be: - // initial request --> - // + initial request <-- - // + retry message - // + retry request --> - // + retry request <-- - // = 5 calls - expect(debugStub.calls.length).toBe(5); - expect(debugStub.calls[2].args[0]).toBe( - 'Request failed with status 429, retrying in 2000ms (attempt 1/3)', - ); - } finally { - sendStub.restore(); - debugStub.restore(); - } - } finally { - fakeTime.restore(); - } + }); }); - await t.step('retries on server error with debug logging', async () => { - const fakeTime = new FakeTime(); - try { - const { mockHttpClient, mockLogger, endpoint } = createEndpointTestSetup(); - const serverErrorResponse = { - status: 503, - headers: { 'content-type': 'application/json' }, - body: { message: 'Service unavailable' }, + await t.step('save() - Create/Update Group', async (t) => { + await t.step('makes POST request for group creation (no id)', async () => { + const newGroup = { + targetName: 'New Group', + recipientType: 'GROUP', + status: 'ACTIVE', + groupType: 'ON_CALL', + description: 'A new test group', }; - const successResponse = { - status: 200, - headers: { 'content-type': 'application/json' }, - body: { count: 1, total: 1, data: [mockGroup] }, - }; - let callCount = 0; - const sendStub = stub(mockHttpClient, 'send', () => { - callCount++; - return callCount === 1 - ? Promise.resolve(serverErrorResponse) - : Promise.resolve(successResponse); - }); - const debugStub = stub(mockLogger, 'debug', () => {}); - try { - // Start the async request but DON'T await it yet - // This begins the async chain but allows us to control timing with FakeTime - const requestPromise = endpoint.get(); - - // Allow the first request to complete and set up the timer - // This advances fake time to let the setTimeout callback fire - await fakeTime.nextAsync(); - - // Verify the first request completed and retry was triggered - // At this point: initial request failed → setTimeout set → timeout fired → retry executed - expect(sendStub.calls.length).toBe(2); - expect(sendStub.calls[0].args[0].method).toBe('GET'); - expect(sendStub.calls[0].args[0].url).toBe('https://example.xmatters.com/api/xm/1/groups'); - expect(sendStub.calls[1].args[0].method).toBe('GET'); - expect(sendStub.calls[1].args[0].url).toBe('https://example.xmatters.com/api/xm/1/groups'); - - // Now advance time to trigger any additional timers (should be none) - await fakeTime.nextAsync(); - - // Finally await the original promise to get the result - // By now all async operations have completed thanks to our time control - const response = await requestPromise; - expect(response.body).toEqual(successResponse.body); - expect(sendStub.calls.length).toBe(2); - // Verify debug logger was called with correct retry message - // Should be: - // initial request --> - // + initial request <-- - // + retry message - // + retry request --> - // + retry request <-- - // = 5 calls - expect(debugStub.calls.length).toBe(5); - expect(debugStub.calls[2].args[0]).toBe( - 'Request failed with status 503, retrying in 1000ms (attempt 1/3)', - ); - } finally { - sendStub.restore(); - debugStub.restore(); - } - } finally { - fakeTime.restore(); - } - }); - - await t.step('logs warning when onTokenRefresh callback throws error', async () => { - // Create a RequestHandler with an onTokenRefresh callback that throws - const throwingCallback = () => { - throw new Error('Callback error'); - }; - const { mockHttpClient, mockLogger, endpoint } = createEndpointTestSetup({ - accessToken: 'expired-token', - refreshToken: 'valid-refresh-token', - clientId: 'test-client-id', - onTokenRefresh: throwingCallback, - expiredToken: true, + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: TestConstants.BASIC_AUTH_HEADERS, + body: newGroup, + }, + mockedResponse: { + status: 201, + headers: { 'content-type': 'application/json' }, + body: { ...newGroup, id: 'new-group-id', created: '2024-01-01T00:00:00.000Z' }, + }, + }]); + await groups.save(newGroup); }); - const unauthorizedResponse = { - status: 401, - headers: { 'content-type': 'application/json' }, - body: { message: 'Token expired' }, - }; - - const tokenRefreshResponse = { - status: 200, - headers: { 'content-type': 'application/json' }, - body: { - access_token: 'new-access-token', - refresh_token: 'new-refresh-token', - expires_in: 3600, - }, - }; - - const successResponse = { - status: 200, - headers: { 'content-type': 'application/json' }, - body: { count: 1, total: 1, data: [mockGroup] }, - }; - let callCount = 0; - const sendStub = stub(mockHttpClient, 'send', (request: HttpRequest) => { - callCount++; - // Check if this is a token refresh request - if (request.url?.includes('/oauth2/token') || request.url?.includes('/oauth2/token')) { - return Promise.resolve(tokenRefreshResponse); - } - // Otherwise it's the main API request - if (callCount === 1) return Promise.resolve(unauthorizedResponse); - return Promise.resolve(successResponse); + await t.step('makes POST request for group update (with id)', async () => { + const existingGroup = { + id: 'existing-group-id', + targetName: 'Updated Group Name', + recipientType: 'GROUP', + status: 'ACTIVE', + groupType: 'ON_CALL', + description: 'Updated description', + created: '2024-01-01T00:00:00.000Z', + }; + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: TestConstants.BASIC_AUTH_HEADERS, + body: existingGroup, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: existingGroup, + }, + }]); + await groups.save(existingGroup); }); - const warnStub = stub(mockLogger, 'warn', () => {}); - try { - const response = await endpoint.get(); - expect(response.status).toBe(200); - expect(sendStub.calls.length).toBe(3); // initial request (401), token refresh, retry request - expect(warnStub.calls.length).toBe(1); - expect(warnStub.calls[0].args[0]).toBe( - 'Error in onTokenRefresh callback, but continuing with refreshed token', - ); - expect(warnStub.calls[0].args[1]).toBeInstanceOf(Error); - expect((warnStub.calls[0].args[1] as Error).message).toBe('Callback error'); - } finally { - sendStub.restore(); - warnStub.restore(); - } - }); - await t.step('logs error when token refresh fails', async () => { - const { mockHttpClient, endpoint } = createEndpointTestSetup({ - accessToken: 'expired-token', - refreshToken: 'invalid-refresh-token', - clientId: 'test-client-id', - expiredToken: true, + await t.step('makes POST request with minimal group data for creation', async () => { + const minimalGroup = { + targetName: 'Minimal Group', + }; + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: TestConstants.BASIC_AUTH_HEADERS, + body: minimalGroup, + }, + mockedResponse: { + status: 201, + headers: { 'content-type': 'application/json' }, + body: mockSingleGroupResponse, + }, + }]); + await groups.save(minimalGroup); }); - const unauthorizedResponse = { - status: 401, - headers: { 'content-type': 'application/json' }, - body: { message: 'Token expired' }, - }; - - const tokenRefreshErrorResponse = { - status: 401, - headers: { 'content-type': 'application/json' }, - body: { code: 401, message: 'Invalid refresh token', reason: 'Unauthorized' }, - }; - - let callCount = 0; - const sendStub = stub(mockHttpClient, 'send', (request: HttpRequest) => { - callCount++; - // Check if this is a token refresh request - if (request.url?.includes('/oauth2/token') || request.url?.includes('/oauth2/token')) { - return Promise.resolve(tokenRefreshErrorResponse); - } - // Otherwise it's the main API request - return Promise.resolve(unauthorizedResponse); + await t.step('makes POST request with custom headers', async () => { + const newGroup = { + targetName: 'New Group', + recipientType: 'GROUP', + }; + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: { + ...TestConstants.BASIC_AUTH_HEADERS, + 'X-Custom-Header': 'custom-value', + }, + body: newGroup, + }, + mockedResponse: { + status: 201, + headers: { 'content-type': 'application/json' }, + body: mockSingleGroupResponse, + }, + }]); + await groups.save(newGroup, { + headers: { + 'X-Custom-Header': 'custom-value', + }, + }); }); - try { - let thrownError: unknown; - try { - await endpoint.get(); - } catch (error) { - thrownError = error; - } - expect(thrownError).toBeInstanceOf(XmApiError); - expect(sendStub.calls.length).toBe(2); // initial request (401), failed token refresh - - // Verify error details are correct - const xmError = thrownError as XmApiError; - expect(xmError.message).toBe('Failed to refresh token'); - expect(xmError.response?.status).toBe(401); - } finally { - sendStub.restore(); - } + await t.step('makes POST request with dynamic group data', async () => { + const dynamicGroup = { + targetName: 'Dynamic Group', + groupType: 'DYNAMIC', + criteria: { + operand: 'OR', + criterion: [{ + criterionType: 'BASIC_FIELD', + field: 'USER_ID', + operand: 'EQUALS', + value: 'MIMTeam1', + }], + }, + supervisors: ['9bccb70b-ab35-4746-b9f5-fa6eca0b1450'], + }; + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/groups', + headers: TestConstants.BASIC_AUTH_HEADERS, + body: dynamicGroup, + }, + mockedResponse: { + status: 201, + headers: { 'content-type': 'application/json' }, + body: mockSingleGroupResponse, + }, + }]); + await groups.save(dynamicGroup); + }); }); - await t.step('handles errors without response body', async () => { - const { mockHttpClient, endpoint } = createEndpointTestSetup(); - const errorResponse = { - status: 400, // Use 400 instead of 502 to avoid retry logic - headers: { 'content-type': 'text/plain' }, - body: '', // Empty response body - }; - const sendStub = stub(mockHttpClient, 'send', () => { - return Promise.resolve(errorResponse); + await t.step('delete() - Delete Group', async (t) => { + await t.step('makes DELETE request with group ID', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'DELETE', + url: 'https://test.xmatters.com/api/xm/1/groups/test-group-id', + headers: TestConstants.BASIC_AUTH_HEADERS, + }, + mockedResponse: { + status: 204, + headers: {}, + body: undefined, + }, + }]); + await groups.delete('test-group-id'); + }); + + await t.step('makes DELETE request with custom headers', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'DELETE', + url: 'https://test.xmatters.com/api/xm/1/groups/test-group-id', + headers: { + ...TestConstants.BASIC_AUTH_HEADERS, + 'X-Custom-Header': 'custom-value', + }, + }, + mockedResponse: { + status: 204, + headers: {}, + body: undefined, + }, + }]); + await groups.delete('test-group-id', { + headers: { + 'X-Custom-Header': 'custom-value', + }, + }); }); - try { - let thrownError: unknown; - try { - await endpoint.get(); - } catch (error) { - thrownError = error; - } - // Verify it was called only once (no retries for 400) - expect(sendStub.calls.length).toBe(1); - expect(thrownError).toBeInstanceOf(XmApiError); - const xmError = thrownError as XmApiError; - expect(xmError.message).toBe('Request failed with status 400'); - expect(xmError.response?.status).toBe(400); - expect(xmError.response?.body).toBe(''); - } finally { - sendStub.restore(); - } }); }); diff --git a/src/endpoints/groups/types.ts b/src/endpoints/groups/types.ts index a6ceddf..bab4b84 100644 --- a/src/endpoints/groups/types.ts +++ b/src/endpoints/groups/types.ts @@ -16,11 +16,25 @@ export interface Group { /** The name of the group used for targeting */ targetName: string; /** Type of recipient */ - recipientType: 'GROUP' | 'DEVICE' | 'PERSON'; + recipientType: + | 'GROUP' + | 'DEVICE' + | 'PERSON' + // deno-lint-ignore ban-types + | (string & {}); // Allows for new recipient types to be used with type assertion /** Whether the group is active or inactive */ - status: 'ACTIVE' | 'INACTIVE'; + status: + | 'ACTIVE' + | 'INACTIVE' + // deno-lint-ignore ban-types + | (string & {}); // Allows for new status values to be used with type assertion /** The type of group */ - groupType: 'ON_CALL' | 'BROADCAST' | 'DYNAMIC'; + groupType: + | 'ON_CALL' + | 'BROADCAST' + | 'DYNAMIC' + // deno-lint-ignore ban-types + | (string & {}); // Allows for new group types to be used with type assertion /** ISO timestamp when the group was created */ created: string; /** Optional description of the group's purpose */ @@ -57,7 +71,12 @@ export interface Group { /** * Individual search field options that can be combined */ -export type GroupSearchField = 'NAME' | 'DESCRIPTION' | 'SERVICE_NAME'; +export type GroupSearchField = + | 'NAME' + | 'DESCRIPTION' + | 'SERVICE_NAME' + // deno-lint-ignore ban-types + | (string & {}); // Allows for new or undocumented search fields to be used with type assertion /** * Type for filters that can be applied when retrieving groups. @@ -81,7 +100,12 @@ export interface GroupFilters extends QueryParams { /** * Specifies the group type to return in the response. */ - groupType?: 'ON_CALL' | 'BROADCAST' | 'DYNAMIC'; + groupType?: + | 'ON_CALL' + | 'BROADCAST' + | 'DYNAMIC' + // deno-lint-ignore ban-types + | (string & {}); // Allows for new group types to be used with type assertion /** * The targetName or id of the users, or devices that are members of an on-call or broadcast group. @@ -95,14 +119,22 @@ export interface GroupFilters extends QueryParams { * - ALL_SHIFTS: Returns groups that have no members added to any shifts * - ANY_SHIFTS: Returns groups that have at least one shift with no members added to it */ - 'member.exists'?: 'ALL_SHIFTS' | 'ANY_SHIFTS'; + 'member.exists'?: + | 'ALL_SHIFTS' + | 'ANY_SHIFTS' + // deno-lint-ignore ban-types + | (string & {}); // Allows for new member existence options to be used with type assertion /** * Returns a list of groups that contain at least one member (or a device that belongs to a user) * who has the specified license type. The member does not have to be part of any shifts for the * group to be included in the response. */ - 'member.licenseType'?: 'FULL_USER' | 'STAKEHOLDER_USER'; + 'member.licenseType'?: + | 'FULL_USER' + | 'STAKEHOLDER_USER' + // deno-lint-ignore ban-types + | (string & {}); // Allows for new license types to be used with type assertion /** * A comma-separated list of sites whose groups you want to retrieve. @@ -126,7 +158,12 @@ export interface GroupSortParams { /** * Field to sort by */ - sortBy?: 'NAME' | 'GROUPTYPE' | 'STATUS'; + sortBy?: + | 'NAME' + | 'GROUPTYPE' + | 'STATUS' + // deno-lint-ignore ban-types + | (string & {}); // Allows for new sort fields to be used with type assertion /** * Sort direction @@ -143,7 +180,9 @@ export type GroupEmbedOptions = | 'supervisors' // Up to the first 100 group supervisors (single group) or paginated list (multiple groups) | 'observers' // Returns the id and name of the role(s) set as observers for the group | 'services' // Returns the list of services owned by the group - | 'criteria'; // Returns the criteria specified for dynamic groups (only applicable when groupType=DYNAMIC) + | 'criteria' // Returns the criteria specified for dynamic groups (only applicable when groupType=DYNAMIC) + // deno-lint-ignore ban-types + | (string & {}); // Allows for new or undocumented embed options to be used with type assertion /** * Type for parameters used when retrieving a single group by identifier. From a70abc20d71ca2bfc78a296a0865a4ca4a23aba3 Mon Sep 17 00:00:00 2001 From: johan Date: Fri, 27 Jun 2025 23:12:41 -0700 Subject: [PATCH 075/101] Continue refactoring unit tests --- .github/copilot-instructions.md | 6 +++- src/core/test-utils.ts | 9 +++-- src/endpoints/groups/index.test.ts | 58 +++++++++++++----------------- 3 files changed, 37 insertions(+), 36 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f4013ed..90d47e2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -12,7 +12,9 @@ The priorities for this project are: and other dependencies. 4. **Type Safety**: The library should be fully type-safe, leveraging TypeScript's capabilities to ensure that consumers get the best developer experience. However, use TypeScript features when - they add value, not just because you can. + they add value, not just because you can. Also there should be zero non-null assertion operators + (!) in the entire codebase. Finally, there are very few exceptions to this rule: whenever you + feel compelled to explicitly specify `undefined`, use `null` instead. 5. **Documentation**: The library should be well-documented, with clear examples and usage instructions. @@ -40,3 +42,5 @@ To run unit tests, always prompt me to run the following command: ```bash deno test ``` + +Sometimes the best solution is the one that requires the least code changes diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts index 3c3b97f..ec1f60b 100644 --- a/src/core/test-utils.ts +++ b/src/core/test-utils.ts @@ -23,7 +23,7 @@ import { expect } from 'std/expect/mod.ts'; */ interface MockRequestResponse { expectedRequest: Partial; - mockedResponse?: HttpResponse; + mockedResponse?: Partial; /** If provided, the request will throw this error instead of returning a response */ mockedError?: Error; } @@ -57,7 +57,12 @@ export class MockHttpClient implements HttpClient { ), ); } - return Promise.resolve(currentPair.mockedResponse); + const response: HttpResponse = { + status: currentPair.mockedResponse.status || 200, + body: currentPair.mockedResponse.body, + headers: currentPair.mockedResponse.headers || {}, + }; + return Promise.resolve(response); } /** diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index 5590206..7d61700 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -14,19 +14,19 @@ const requestHandler = new RequestHandler({ const groups = new GroupsEndpoint(requestHandler); -const mockSingleGroupResponse = { +const mockSingleGroupBody = { id: 'test-group-id', targetName: 'Test Group', recipientType: 'GROUP', status: 'ACTIVE', groupType: 'ON_CALL', - created: '2024-01-01T00:00:00.000Z', + created: '2025-01-01T00:00:00.000Z', }; -const mockPaginatedGroupsResponse = { +const mockPaginatedGroupsBody = { count: 1, total: 1, - data: [mockSingleGroupResponse], + data: [mockSingleGroupBody], links: { self: '/api/xm/1/groups?limit=100&offset=0', }, @@ -44,7 +44,7 @@ Deno.test('GroupsEndpoint', async (t) => { mockedResponse: { status: 200, headers: { 'content-type': 'application/json' }, - body: mockPaginatedGroupsResponse, + body: mockPaginatedGroupsBody, }, }]); await groups.get(); @@ -60,7 +60,7 @@ Deno.test('GroupsEndpoint', async (t) => { mockedResponse: { status: 200, headers: { 'content-type': 'application/json' }, - body: mockPaginatedGroupsResponse, + body: mockPaginatedGroupsBody, }, }]); await groups.get({ @@ -82,7 +82,7 @@ Deno.test('GroupsEndpoint', async (t) => { mockedResponse: { status: 200, headers: { 'content-type': 'application/json' }, - body: mockPaginatedGroupsResponse, + body: mockPaginatedGroupsBody, }, }]); await groups.get({ @@ -111,7 +111,7 @@ Deno.test('GroupsEndpoint', async (t) => { mockedResponse: { status: 200, headers: { 'content-type': 'application/json' }, - body: mockPaginatedGroupsResponse, + body: mockPaginatedGroupsBody, }, }]); await groups.get({ @@ -132,7 +132,7 @@ Deno.test('GroupsEndpoint', async (t) => { mockedResponse: { status: 200, headers: { 'content-type': 'application/json' }, - body: mockPaginatedGroupsResponse, + body: mockPaginatedGroupsBody, }, }]); await groups.get({ @@ -156,7 +156,7 @@ Deno.test('GroupsEndpoint', async (t) => { mockedResponse: { status: 200, headers: { 'content-type': 'application/json' }, - body: mockSingleGroupResponse, + body: mockSingleGroupBody, }, }]); await groups.getByIdentifier('test-group-id'); @@ -172,10 +172,10 @@ Deno.test('GroupsEndpoint', async (t) => { mockedResponse: { status: 200, headers: { 'content-type': 'application/json' }, - body: mockSingleGroupResponse, + body: mockSingleGroupBody, }, }]); - await groups.getByIdentifier('Oracle%20Administrators'); + await groups.getByIdentifier('Oracle Administrators'); }); await t.step('makes GET request with embed parameters', async () => { @@ -189,7 +189,7 @@ Deno.test('GroupsEndpoint', async (t) => { mockedResponse: { status: 200, headers: { 'content-type': 'application/json' }, - body: mockSingleGroupResponse, + body: mockSingleGroupBody, }, }]); await groups.getByIdentifier('test-group-id', { @@ -212,7 +212,7 @@ Deno.test('GroupsEndpoint', async (t) => { mockedResponse: { status: 200, headers: { 'content-type': 'application/json' }, - body: mockSingleGroupResponse, + body: mockSingleGroupBody, }, }]); await groups.getByIdentifier('test-group-id', { @@ -242,7 +242,7 @@ Deno.test('GroupsEndpoint', async (t) => { mockedResponse: { status: 201, headers: { 'content-type': 'application/json' }, - body: { ...newGroup, id: 'new-group-id', created: '2024-01-01T00:00:00.000Z' }, + body: { ...newGroup, id: 'new-group-id', created: '2025-01-01T00:00:00.000Z' }, }, }]); await groups.save(newGroup); @@ -256,7 +256,7 @@ Deno.test('GroupsEndpoint', async (t) => { status: 'ACTIVE', groupType: 'ON_CALL', description: 'Updated description', - created: '2024-01-01T00:00:00.000Z', + created: '2025-01-01T00:00:00.000Z', }; mockHttpClient.setReqRes([{ expectedRequest: { @@ -276,7 +276,7 @@ Deno.test('GroupsEndpoint', async (t) => { await t.step('makes POST request with minimal group data for creation', async () => { const minimalGroup = { - targetName: 'Minimal Group', + targetName: mockSingleGroupBody.targetName, }; mockHttpClient.setReqRes([{ expectedRequest: { @@ -288,7 +288,7 @@ Deno.test('GroupsEndpoint', async (t) => { mockedResponse: { status: 201, headers: { 'content-type': 'application/json' }, - body: mockSingleGroupResponse, + body: mockSingleGroupBody, }, }]); await groups.save(minimalGroup); @@ -296,8 +296,8 @@ Deno.test('GroupsEndpoint', async (t) => { await t.step('makes POST request with custom headers', async () => { const newGroup = { - targetName: 'New Group', - recipientType: 'GROUP', + targetName: mockSingleGroupBody.targetName, + recipientType: mockSingleGroupBody.recipientType, }; mockHttpClient.setReqRes([{ expectedRequest: { @@ -312,7 +312,7 @@ Deno.test('GroupsEndpoint', async (t) => { mockedResponse: { status: 201, headers: { 'content-type': 'application/json' }, - body: mockSingleGroupResponse, + body: mockSingleGroupBody, }, }]); await groups.save(newGroup, { @@ -324,7 +324,7 @@ Deno.test('GroupsEndpoint', async (t) => { await t.step('makes POST request with dynamic group data', async () => { const dynamicGroup = { - targetName: 'Dynamic Group', + targetName: mockSingleGroupBody.targetName, groupType: 'DYNAMIC', criteria: { operand: 'OR', @@ -347,7 +347,7 @@ Deno.test('GroupsEndpoint', async (t) => { mockedResponse: { status: 201, headers: { 'content-type': 'application/json' }, - body: mockSingleGroupResponse, + body: mockSingleGroupBody, }, }]); await groups.save(dynamicGroup); @@ -362,11 +362,7 @@ Deno.test('GroupsEndpoint', async (t) => { url: 'https://test.xmatters.com/api/xm/1/groups/test-group-id', headers: TestConstants.BASIC_AUTH_HEADERS, }, - mockedResponse: { - status: 204, - headers: {}, - body: undefined, - }, + mockedResponse: { status: 204 }, }]); await groups.delete('test-group-id'); }); @@ -381,11 +377,7 @@ Deno.test('GroupsEndpoint', async (t) => { 'X-Custom-Header': 'custom-value', }, }, - mockedResponse: { - status: 204, - headers: {}, - body: undefined, - }, + mockedResponse: { status: 204 }, }]); await groups.delete('test-group-id', { headers: { From 2ea549fbe826f6c9d8181471325f23391cafd88d Mon Sep 17 00:00:00 2001 From: johan Date: Sat, 28 Jun 2025 00:27:37 -0700 Subject: [PATCH 076/101] Get started on cleaning up readme --- .github/copilot-instructions.md | 84 ++++++++++++++++++++------------- README.maintainers.md | 50 ++++++++++++++++++++ README.md | 48 ------------------- deno.json | 1 - 4 files changed, 102 insertions(+), 81 deletions(-) create mode 100644 README.maintainers.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 90d47e2..6482845 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,46 +1,66 @@ -This typescript project built with deno is meant to be a library for javascript developers to -consume the xMatters API (AKA xmApi). - -The priorities for this project are: - -1. **Consistency**: Both from the library maintainers' perspective and the library consumers' - perspective, code should be consistent in style, structure, and behavior. This includes - consistent naming conventions, error handling, and response structures. -2. **Zero Dependencies**: The library should not depend on any other libraries, except for Deno's - standard library and even then, only for unit testing. -3. **Dependency Injection**: The consumer should be able to inject their own HTTP client, logger, - and other dependencies. -4. **Type Safety**: The library should be fully type-safe, leveraging TypeScript's capabilities to - ensure that consumers get the best developer experience. However, use TypeScript features when - they add value, not just because you can. Also there should be zero non-null assertion operators - (!) in the entire codebase. Finally, there are very few exceptions to this rule: whenever you - feel compelled to explicitly specify `undefined`, use `null` instead. -5. **Documentation**: The library should be well-documented, with clear examples and usage +This TypeScript project, built with Deno, is a library for JavaScript developers to consume the +xMatters API (aka `xmApi`). + +### Project Priorities + +1. **Good DX**: + +- The library should provide a great developer experience (DX) for JavaScript and TypeScript + developers who use it (consumers), and for those who develop it (maintainers). +- When these 2 priorities are at odds, the library should prioritize a good DX for consumers. +- It should be easy to use, with clear and concise APIs that are intuitive to understand. + +2. **Consistency**: + +- Code should be consistent in **style**, **structure**, and **behavior**. +- Follow consistent **naming conventions**, **error handling**, and **response structures**. + +3. **Zero Dependencies**: + +- Do not use any third-party libraries. +- Only use Deno’s standard library, and only for **unit testing**. + +4. **Dependency Injection**: + +- Consumers must be able to inject their own: + - HTTP client + - Logger + - Any other external dependencies when applicable + +5. **Type Safety**: + +- Use TypeScript features when they add value, not just because you can. +- Avoid the non-null assertion operator (`!`) entirely. +- Prefer `null` over `undefined` when explicitly assigning absence of a value. + +6. **Documentation**: The library should be well-documented, with clear examples and usage instructions. -We need to implement something that will make it extremely easy for the maintainers to add more -endpoints in the future. The core logic of how a request is built and sent should be abstracted away -from the endpoint implementations. +### Development Guidelines -For iterative development, you can make use of the /sandbox/index.ts file to test your code. This -file is not part of the library and is meant for quick prototyping and testing. Do not modify the -sandbox unless explicitly instructed to do so. +It should be extremely easy for the maintainers to add more endpoints in the future. The core logic +of how a request is built and sent should be abstracted away from the endpoint implementations. -To run the sandbox, always prompt me to run the following command: +- Do not make changes until you are **95% confident** in what needs to be built. +- Ask clarifying questions until you reach that level of confidence. + +#### The Sandbox + +- `/sandbox/index.ts` is for quick prototyping and testing. +- Though version controlled, this file is **not** part of the library shipped to consumers. +- Do **not** modify the sandbox unless explicitly instructed. +- Must be run with: ```bash deno task sandbox ``` -Unlike the sandbox, unit tests should not send actual HTTP requests over the network, ever. +#### Unit Tests -Do not make any changes until you have 95% confidence that you know what to build. Ask me follow up -questions until you have that confidence. - -To run unit tests, always prompt me to run the following command: +- **Never** send real HTTP requests. +- Use _mocks_ or _stubs_ for all external interactions. +- Must be run with: ```bash deno test ``` - -Sometimes the best solution is the one that requires the least code changes diff --git a/README.maintainers.md b/README.maintainers.md new file mode 100644 index 0000000..2418a4b --- /dev/null +++ b/README.maintainers.md @@ -0,0 +1,50 @@ +# xM API SDK JS + +`xmas` for short 🎄 + +### Maintainers + +> **Setup**: After cloning, run `deno install` to install and cache all dependencies. + +> **VS Code Users**: When you first open this project, VS Code will suggest installing the Deno +> extension. Accept this suggestion to get proper TypeScript support, formatting, and linting as +> configured in the [.vscode/settings.json](.vscode/settings.json) file. + +> **Corporate Networks**: If you're behind a corporate firewall (like Zscaler), you may encounter +> SSL certificate issues when downloading dependencies. Use the configured tasks instead of direct +> Deno commands: + +**Development Commands**: + +- `deno test` - Run all unit tests +- `deno task cache` - Cache dependencies (handles corporate certificates) +- `deno task sandbox` - Run sandbox for quick prototyping +- `deno task sandbox:validate-docs` - You should modify `sandbox/validate-docs.ts` to create AI-generated code that leverages the SDK to validate specific examples and behaviors from the official API documentation. Then, run this command to verify the implementation matches the documented behavior. + +**Alternative Commands** (if not behind corporate firewall): + +- `deno cache --reload src/**/*.ts` - Cache dependencies + +### Troubleshooting + +**SSL Certificate Issues**: If you encounter errors like `invalid peer certificate: UnknownIssuer` +when running Deno commands, you're likely behind a corporate firewall that intercepts SSL +certificates. + +**Solution**: Use the configured tasks which include `DENO_TLS_CA_STORE=system`: + +```bash +deno task cache # Instead of: deno cache --reload src/**/*.ts +``` + +**Manual Override**: For any other Deno command, prefix with the environment variable: + +```bash +DENO_TLS_CA_STORE=system deno [your-command] +``` + +**Permanent Fix**: Add this to your shell profile (`~/.zshrc`): + +```bash +export DENO_TLS_CA_STORE=system +``` \ No newline at end of file diff --git a/README.md b/README.md index c2b5440..140e14a 100644 --- a/README.md +++ b/README.md @@ -2,54 +2,6 @@ `xmas` for short 🎄 -### Maintainers - -> **Setup**: After cloning, run `deno install` to install and cache all dependencies. - -> **VS Code Users**: When you first open this project, VS Code will suggest installing the Deno -> extension. Accept this suggestion to get proper TypeScript support, formatting, and linting as -> configured in the [.vscode/settings.json](.vscode/settings.json) file. - -> **Corporate Networks**: If you're behind a corporate firewall (like Zscaler), you may encounter -> SSL certificate issues when downloading dependencies. Use the configured tasks instead of direct -> Deno commands: - -**Development Commands**: - -- `deno task test` - Run all unit tests (handles corporate certificates) -- `deno task cache` - Cache dependencies (handles corporate certificates) -- `deno run --allow-net sandbox/index.ts` - Run sandbox for quick prototyping - -**Alternative Commands** (if not behind corporate firewall): - -- `deno test` - Run all unit tests -- `deno cache --reload src/**/*.ts` - Cache dependencies - -### Troubleshooting - -**SSL Certificate Issues**: If you encounter errors like `invalid peer certificate: UnknownIssuer` -when running Deno commands, you're likely behind a corporate firewall that intercepts SSL -certificates. - -**Solution**: Use the configured tasks which include `DENO_TLS_CA_STORE=system`: - -```bash -deno task test # Instead of: deno test -deno task cache # Instead of: deno cache --reload src/**/*.ts -``` - -**Manual Override**: For any other Deno command, prefix with the environment variable: - -```bash -DENO_TLS_CA_STORE=system deno [your-command] -``` - -**Permanent Fix**: Add this to your shell profile (`~/.zshrc`): - -```bash -export DENO_TLS_CA_STORE=system -``` - # Usage If your project already relies on the `axios` library, `xmas` will just use it to send http requests diff --git a/deno.json b/deno.json index b5fa08c..6e6c2f3 100644 --- a/deno.json +++ b/deno.json @@ -6,7 +6,6 @@ "std/": "https://deno.land/std@0.224.0/" }, "tasks": { - "test": "DENO_TLS_CA_STORE=system deno test", "cache": "DENO_TLS_CA_STORE=system deno cache --reload src/**/*.ts", "sandbox": "DENO_TLS_CA_STORE=system deno run --allow-read --allow-net --env-file=sandbox/.env --allow-env sandbox/index.ts", "sandbox:validate-docs": "DENO_TLS_CA_STORE=system deno run --allow-read --allow-net --env-file=sandbox/.env --allow-env sandbox/validate-docs.ts" From 63b6f6464fff93bea45e29e45a5ba695984b5a0f Mon Sep 17 00:00:00 2001 From: johan Date: Sat, 28 Jun 2025 00:28:02 -0700 Subject: [PATCH 077/101] Get started on cleaning up readme --- README.maintainers.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.maintainers.md b/README.maintainers.md index 2418a4b..2aa7680 100644 --- a/README.maintainers.md +++ b/README.maintainers.md @@ -19,7 +19,10 @@ - `deno test` - Run all unit tests - `deno task cache` - Cache dependencies (handles corporate certificates) - `deno task sandbox` - Run sandbox for quick prototyping -- `deno task sandbox:validate-docs` - You should modify `sandbox/validate-docs.ts` to create AI-generated code that leverages the SDK to validate specific examples and behaviors from the official API documentation. Then, run this command to verify the implementation matches the documented behavior. +- `deno task sandbox:validate-docs` - You should modify `sandbox/validate-docs.ts` to create + AI-generated code that leverages the SDK to validate specific examples and behaviors from the + official API documentation. Then, run this command to verify the implementation matches the + documented behavior. **Alternative Commands** (if not behind corporate firewall): @@ -47,4 +50,4 @@ DENO_TLS_CA_STORE=system deno [your-command] ```bash export DENO_TLS_CA_STORE=system -``` \ No newline at end of file +``` From 7c801c13ce2cd065969b9763fd36e4728699eadb Mon Sep 17 00:00:00 2001 From: johan Date: Sat, 28 Jun 2025 10:46:59 -0700 Subject: [PATCH 078/101] Refresh the READMEs --- README.maintainers.md | 29 +++ README.md | 321 +++++++++++++++++++++++--------- src/core/types/internal/http.ts | 2 +- 3 files changed, 268 insertions(+), 84 deletions(-) diff --git a/README.maintainers.md b/README.maintainers.md index 2aa7680..9bc239c 100644 --- a/README.maintainers.md +++ b/README.maintainers.md @@ -51,3 +51,32 @@ DENO_TLS_CA_STORE=system deno [your-command] ```bash export DENO_TLS_CA_STORE=system ``` + +### Adding New Endpoints + +The library is designed to make adding new endpoints extremely easy. Each endpoint follows the same pattern: + +1. **Create a new directory** under `src/endpoints/` (e.g., `src/endpoints/people/`) +2. **Define types** in `types.ts` for the endpoint's request/response models +3. **Implement the endpoint class** using `ResourceClient` for HTTP operations in `index.ts` (standard pattern - oauth endpoint is a rare and justified exception) +4. **Export from the main index.ts** to make it available to consumers + +### Project Structure + +``` +src/ +├── index.ts # Main entry point +├── core/ # Core functionality +│ ├── request-handler.ts # HTTP request management +│ ├── resource-client.ts # Base class for endpoints +│ ├── errors.ts # Error definitions +│ ├── defaults/ # Default implementations (httpClient and logger) +│ ├── types/ # TypeScript interfaces +│ └── utils/ # Utility functions +└── endpoints/ # API endpoint implementations + ├── groups/ # Groups API + ├── oauth/ # OAuth API + └── [new-endpoint]/ # Your new endpoint here +``` + +The core abstractions handle all the complex HTTP logic, authentication, retries, and error handling - you just focus on the endpoint-specific business logic. diff --git a/README.md b/README.md index 140e14a..741665b 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,26 @@ `xmas` for short 🎄 +A TypeScript/JavaScript library for interacting with the xMatt// xmApi will now automatically use OAuth tokens for all subsequent requests +const groups = await xmApi.groups.get(); +``` + +The library will automatically start using the OAuth tokens and purge the username & password from memory for security.PI. + +- 🎄 **Zero dependencies** - Uses only native fetch API +- 🔒 **Multiple auth methods** - Basic auth, OAuth, and authorization code flow +- 🔧 **Dependency injection** - Bring your own HTTP client and logger +- 📝 **Full TypeScript support** - Complete type safety and IntelliSense +- 🔄 **Automatic token refresh** - Handles OAuth token lifecycle + # Usage -If your project already relies on the `axios` library, `xmas` will just use it to send http requests -to `xmApi` and handle its responses. +## Basic Authentication -Your instantiation of a new Xmas object will only need your xM hostname and some auth credentials: +For simple username/password authentication: -```js -const Xmas = require('xmas'); +```ts +import { XmApi } from '@johanfive/xmas'; const config = { hostname: 'https://yourOrg.xmatters.com', @@ -18,11 +29,11 @@ const config = { password: 'authingUserPassword', }; -const xmas = new Xmas(config); +const xmApi = new XmApi(config); // Create a new group in your xMatters instance: const group = { targetName: 'API developers' }; -const response = await xmas.groups.save(group); +const response = await xmApi.groups.save(group); // Access the HTTP response details: console.log('Status:', response.status); @@ -30,133 +41,277 @@ console.log('Headers:', response.headers); console.log('Created group:', response.body); // Get groups with pagination: -const groupsResponse = await xmas.groups.get({ limit: 10, offset: 0 }); +const groupsResponse = await xmApi.groups.get({ + query: { offset: 5, limit: 10 } +}); console.log('Total groups:', groupsResponse.body.total); groupsResponse.body.data.forEach((group) => { console.log('Group:', group.targetName); }); ``` -Alternative `config` object for **OAuth** (say when you have already generated tokens and safely -stored them in your DB): +## OAuth Configuration -```json -{ - "hostname": "https://yourOrg.xmatters.com", - "accessToken": "eyJ123...", - "refreshToken": "eyJ456...", - "clientId": "Your xMatters instance uuid" -} +If you already have OAuth tokens: + +```ts +const config = { + hostname: 'https://yourOrg.xmatters.com', + accessToken: 'eyJ123...', + refreshToken: 'eyJ456...', + clientId: 'your-client-id', +}; + +const xmApi = new XmApi(config); +``` + +## Authorization Code Flow + +If you have an authorization code from the OAuth flow: + +```ts +const config = { + hostname: 'https://yourOrg.xmatters.com', + authorizationCode: 'auth_code_from_callback', + clientId: 'your-client-id', + clientSecret: 'your-client-secret', // Optional for enhanced security +}; + +const xmApi = new XmApi(config); +// Obtain tokens before making API calls +await xmApi.oauth.obtainTokens(); ``` -## Obtain OAuth tokens +## Obtain OAuth tokens with basic auth -```js -const Xmas = require('xmas'); +```ts +import { XmApi } from '@johanfive/xmas'; const config = { hostname: 'https://yourOrg.xmatters.com', username: 'authingUserName', password: 'authingUserPassword', + onTokenRefresh: (accessToken, refreshToken) => { + // Save tokens when they're obtained/refreshed + saveTokensToDatabase({ accessToken, refreshToken }); + }, }; -const xmas = new Xmas(config); -xmas.getOauthTokens.byUsernamePassword() - .then(({ accessToken, refreshToken }) => saveToDb(accessToken, refreshToken)) - .then(() => xmas.people.search('immediately uses the tokens, not the creds set in config')) - .catch(handleError); +const xmApi = new XmApi(config); +// Obtain tokens and automatically transition to OAuth +await xmApi.oauth.obtainTokens({ + clientId: 'your-client-id' +}); +// xmApi will now automatically use OAuth tokens for all subsequent requests +const groups = await xmApi.groups.get(); ``` -`Xmas` will immediately start using the OAuth tokens and stop using the username & password you -instantiated it with. +The library will automatically start using the OAuth tokens and purge the username & password you instantiated it with. ## Dependency injection -If your project relies on an **HTTP client** _other_ than `axios`, you will need to pass it in the -`config` when you instantiate an Xmas: +The library uses dependency injection to allow you to provide your own implementations for HTTP clients, loggers, and other dependencies. + +### Custom HTTP Client + +If you want to use your own HTTP client implementation: + +```ts +import type { HttpClient, HttpRequest, HttpResponse } from '@johanfive/xmas'; + +const myHttpClient: HttpClient = { + async send(request: HttpRequest): Promise { + // Your HTTP client implementation + const response = await yourHttpLibrary({ + method: request.method, + url: request.url, + headers: request.headers, + body: request.body, + }); + + return { + status: response.status, + headers: response.headers, + body: response.data, + }; + }, +}; + +// Important: Your HTTP client should NOT throw on HTTP error status codes (4xx, 5xx) +// Instead, return the response normally +// This differs from libraries like Axios that by default throw on error responses +// +// While throwing can feel more natural at first, an HTTP response that contains an error status code is still a response, which is expected behaviour. That is not to say the http client should never throw. +// +// The library aligns with the Fetch API approach because it enables: +// - Better error message formatting with full response context +// - Smarter retry logic +// - Consistent error handling across all HTTP clients (fetch, axios, custom, etc.) -```js const config = { hostname: 'https://yourOrg.xmatters.com', username: 'authingUserName', password: 'authingUserPassword', - httpClient: { - sendRequest: yourHttpClient, - successAdapter: () => {}, - failureAdapter: () => {}, - }, + httpClient: myHttpClient, }; ``` -### httpClient.sendRequet +### Custom Logger -Should have the following signature: +The library uses `console` for logging by default, which works well for most applications. You only need to provide a custom logger if you want different behaviors. -```js -(({ method, url, headers, data }) => Promise); -``` +**To use your own logging library:** -Where: +Most popular logging libraries (Winston, Pino, etc.) should be directly compatible: -- `method` will be an HTTP method used to send the request (eg: 'GET', 'POST', 'DELETE') -- `url` will be the fully qualified url the request will be sent to (eg: - 'https://yourOrg.xmatters.com/api/xm/1/people?firstName=peter&lastName=parker') -- `headers` will be a typical HTTP request headers object to send to xM API -- `data` (optional) will be the stringified payload to send to xM API +```ts +const winston = require('winston'); -Your HTTP client should know what to do with those and must return a `promise`. +const config = { + hostname: 'https://yourOrg.xmatters.com', + username: 'authingUserName', + password: 'authingUserPassword', + logger: winston, +}; +``` -### httpClient.successAdapter +Or if you need a custom wrapper: -This is a function that will receive the response in the exact format your HTTP client usually -returns it upon a successful request (2xx). +```ts +import type { Logger } from '@johanfive/xmas'; -Think the very first `.then()` called when your HTTP client promise `resolves`. +const myCustomLogger: Logger = { + debug: (message: string, ...args: unknown[]) => myLogLibrary.debug(message, ...args), + info: (message: string, ...args: unknown[]) => myLogLibrary.info(message, ...args), + warn: (message: string, ...args: unknown[]) => myLogLibrary.warn(message, ...args), + error: (message: string, ...args: unknown[]) => myLogLibrary.error(message, ...args), +}; +``` -This function must _only_ return the xmApi `payload`/`response body`. +**To silence all logging:** -Here is an example of the adapter used for the axios HTTP client under the hood: +If you prefer to completely disable logging (rather than configuring log levels in your logging library): -```js -const axiosSuccessAdapter = (res) => res.data; -``` +```ts +const silentLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; -### httpClient.failureAdapter +const config = { + hostname: 'https://yourOrg.xmatters.com', + username: 'authingUserName', + password: 'authingUserPassword', + logger: silentLogger, +}; +``` -This is a function that will receive the error in the exact format your HTTP client usually throws -it upon a failed request (non 2xx). +### Token Refresh Callback -Think the `.catch()` called when your HTTP client promise `rejects`. +To handle OAuth token refresh events: -This function must throw (rethrow, technically) an error object with both a `status` and a `payload` -property attached to it. +```ts +import type { TokenRefreshCallback } from '@johanfive/xmas'; -Here is an example of the adapter used for the axios HTTP client under the hood: +const onTokenRefresh: TokenRefreshCallback = async (accessToken, refreshToken) => { + // Save tokens to your database or secure storage + await saveTokensToDatabase({ accessToken, refreshToken }); +}; -```js -const axiosFailureAdapter = (e) => { - const humanReadableMessage = e.response - ? `xM API responded with ${e.response.status} ${e.response.statusText}` - : 'Something went wrong and no response was received from xM API'; - const error = new Error(humanReadableMessage); - error.status = e.response?.status; - error.payload = e.response?.data; - throw error; +const config = { + hostname: 'https://yourOrg.xmatters.com', + accessToken: 'current_access_token', + refreshToken: 'current_refresh_token', + clientId: 'your-client-id', + onTokenRefresh, }; ``` -The human readable message can be omitted and the object thrown doesn't even have to be an Error -instance, these are just nice things. What is important is that this function `throws`, and that the -object thrown contains a `status` and a `payload` property. Where: +## HTTP Client Interface + +The `HttpClient` interface that your custom implementation must satisfy: -- `status` must be an _integer_: the http **status code** of the response -- `payload` must be the xM API **response body** if one was returned +```ts +interface HttpRequest { + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + url: string; // Fully qualified URL + headers?: Headers; // Key-value pairs of HTTP headers + body?: unknown; // Request body (will be serialized) + retryAttempt?: number; // Current retry attempt for logging +} -```sh -# If all of this seems like more trouble than having to manage 1 more dependency in your project, -# then you can simply run: -npm i axios -# and start using the SDK as is. +interface HttpResponse { + body: T; // Parsed response body + status: number; // HTTP status code + headers: Headers; // Response headers +} -# We just thought it would be nice not to arbitrarily impose yet another dependency on your project. +interface HttpClient { + send: (request: HttpRequest) => Promise; +} +``` + +**Note:** The `method` field is restricted to the HTTP methods that this library needs to interact with the xMatters API. Your HTTP client implementation can support additional methods (like `OPTIONS`, `HEAD`, etc.) - this restriction only applies to requests that the library will send to your client. + +Your HTTP client receives a fully prepared request with: +- Complete URL (including query parameters) +- All necessary headers (including authentication) +- Serialized request body (if applicable) + +The library uses the native `fetch` API by default, so you only need to provide a custom HTTP client if you have specific requirements (like using a different HTTP library or adding custom retry logic). + +## Error Handling + +The library throws `XmApiError` instances for API-related errors: + +```ts +import { XmApiError } from '@johanfive/xmas'; + +try { + const response = await xmApi.groups.save(group); +} catch (error) { + if (error instanceof XmApiError) { + console.log('API Error:', error.message); + if (error.response) { + console.log('Status Code:', error.response.status); + console.log('Response Body:', error.response.body); + console.log('Response Headers:', error.response.headers); + } + // Access underlying cause if available + if (error.cause) { + console.log('Underlying error:', error.cause); + } + } +} +``` + +## Configuration Options + +All configuration options: + +```ts +interface XmApiConfig { + // Required + hostname: string; + + // Authentication (one of these sets required) + username?: string; // Basic auth + password?: string; // Basic auth + authorizationCode?: string; // Auth code flow + accessToken?: string; // OAuth + refreshToken?: string; // OAuth + clientId?: string; // OAuth/Auth code + clientSecret?: string; // Optional for enhanced security + + // Optional dependencies + httpClient?: HttpClient; // Custom HTTP implementation + logger?: Logger; // Custom logging implementation + + // Optional settings + defaultHeaders?: Headers; // Additional headers for all requests + maxRetries?: number; // Maximum retry attempts + onTokenRefresh?: TokenRefreshCallback; // Handle token refresh events +} ``` diff --git a/src/core/types/internal/http.ts b/src/core/types/internal/http.ts index af1a92a..dfcca59 100644 --- a/src/core/types/internal/http.ts +++ b/src/core/types/internal/http.ts @@ -35,7 +35,7 @@ export interface HttpRequest { headers?: Headers; /** Optional request body (injected HTTP client should handle serialization) */ body?: unknown; - /** Current retry attempt number (for logging/debugging by HTTP clients) */ + /** Current retry attempt number (used by retry mechanism; available for logging/debugging in HTTP clients) */ retryAttempt?: number; } From 984d354c17a13ac09f57e878b119305093b05be7 Mon Sep 17 00:00:00 2001 From: johan Date: Sat, 28 Jun 2025 14:44:25 -0700 Subject: [PATCH 079/101] Implement /people endpoint with A.I. and a snippet Discover quirk about encoded url paths, implement fix Revisit doc --- .vscode/typescript.code-snippets | 93 ++++++ README.maintainers.md | 54 +++- README.md | 103 ++++--- src/core/request-builder.ts | 64 ++-- src/endpoints/groups/index.test.ts | 4 +- src/endpoints/people/index.test.ts | 467 +++++++++++++++++++++++++++++ src/endpoints/people/index.ts | 84 ++++++ src/endpoints/people/types.ts | 327 ++++++++++++++++++++ src/index.ts | 5 +- 9 files changed, 1126 insertions(+), 75 deletions(-) create mode 100644 .vscode/typescript.code-snippets create mode 100644 src/endpoints/people/index.test.ts create mode 100644 src/endpoints/people/index.ts create mode 100644 src/endpoints/people/types.ts diff --git a/.vscode/typescript.code-snippets b/.vscode/typescript.code-snippets new file mode 100644 index 0000000..78fd85e --- /dev/null +++ b/.vscode/typescript.code-snippets @@ -0,0 +1,93 @@ +{ + "xMatters Endpoint Index": { + "prefix": "xm-endpoint", + "description": "Generate a complete endpoint index.ts file", + "body": [ + "import { ResourceClient } from '../../core/resource-client.ts';", + "import type { RequestHandler } from '../../core/request-handler.ts';", + "import type { HttpResponse } from '../../core/types/internal/http.ts';", + "import type {", + " DeleteOptions,", + " GetOptions,", + " RequestWithBodyOptions,", + "} from '../../core/types/internal/http-methods.ts';", + "import type {", + " EmptyHttpResponse,", + " PaginatedHttpResponse,", + "} from '../../core/types/endpoint/response.ts';", + "import type { Get${1:Resource}Params, Get${1:Resource}sParams, Get${1:Resource}sResponse, ${1:Resource} } from './types.ts';", + "", + "/**", + " * Provides access to the ${2:${1/(.*)/${1:/downcase}/}} endpoints of the xMatters API.", + " * Use this class to manage ${2:${1/(.*)/${1:/downcase}/}}s, including listing, creating, updating, and deleting ${2:${1/(.*)/${1:/downcase}/}}s.", + " */", + "export class ${1:Resource}sEndpoint {", + " private readonly http: ResourceClient;", + "", + " constructor(http: RequestHandler) {", + " this.http = new ResourceClient(http, '/${3:${1/(.*)/${1:/downcase}/}}s');", + " }", + "", + " /**", + " * Get a list of ${2:${1/(.*)/${1:/downcase}/}}s from xMatters.", + " * The results can be filtered and paginated using the options object.", + " *", + " * @param options Optional parameters including query filters, headers, and other request options", + " * @returns The HTTP response containing a paginated list of ${2:${1/(.*)/${1:/downcase}/}}s", + " * @throws {XmApiError} If the request fails", + " */", + " get(", + " options?: Omit & { path?: string; query?: Get${1:Resource}sParams },", + " ): Promise> {", + " return this.http.get(options);", + " }", + "", + " /**", + " * Get a ${2:${1/(.*)/${1:/downcase}/}} by its ID or targetName.", + " *", + " * @param identifier The ID or targetName of the ${2:${1/(.*)/${1:/downcase}/}} to retrieve", + " * @param options Optional request options including embed parameters and headers", + " * @returns The HTTP response containing the ${2:${1/(.*)/${1:/downcase}/}}", + " * @throws {XmApiError} If the request fails", + " */", + " getByIdentifier(", + " identifier: string,", + " options?: Omit & { query?: Get${1:Resource}Params },", + " ): Promise> {", + " return this.http.get<${1:Resource}>({ ...options, path: identifier });", + " }", + "", + " /**", + " * Create a new ${2:${1/(.*)/${1:/downcase}/}} or update an existing one", + " *", + " * @param ${2:${1/(.*)/${1:/downcase}/}} The ${2:${1/(.*)/${1:/downcase}/}} to create or update", + " * @param overrides Optional request overrides like custom headers", + " * @returns The HTTP response containing the created or updated ${2:${1/(.*)/${1:/downcase}/}}", + " * @throws {XmApiError} If the request fails", + " */", + " save(", + " ${2:${1/(.*)/${1:/downcase}/}}: Partial<${1:Resource}>,", + " overrides?: Omit,", + " ): Promise> {", + " return this.http.post<${1:Resource}>({ ...overrides, body: ${2:${1/(.*)/${1:/downcase}/}} });", + " }", + "", + " /**", + " * Delete a ${2:${1/(.*)/${1:/downcase}/}} by ID", + " *", + " * @param id The ID of the ${2:${1/(.*)/${1:/downcase}/}} to delete", + " * @param overrides Optional request overrides like custom headers", + " * @returns The HTTP response", + " * @throws {XmApiError} If the request fails", + " */", + " delete(", + " id: string,", + " overrides?: Omit,", + " ): Promise {", + " return this.http.delete({ ...overrides, path: id });", + " }", + "}", + "$0" + ] + } +} diff --git a/README.maintainers.md b/README.maintainers.md index 9bc239c..dc6ade3 100644 --- a/README.maintainers.md +++ b/README.maintainers.md @@ -19,10 +19,8 @@ - `deno test` - Run all unit tests - `deno task cache` - Cache dependencies (handles corporate certificates) - `deno task sandbox` - Run sandbox for quick prototyping -- `deno task sandbox:validate-docs` - You should modify `sandbox/validate-docs.ts` to create - AI-generated code that leverages the SDK to validate specific examples and behaviors from the - official API documentation. Then, run this command to verify the implementation matches the - documented behavior. +- `deno task sandbox:validate-docs` - Validate SDK behavior against official API documentation (see + Adding New Endpoints section) **Alternative Commands** (if not behind corporate firewall): @@ -54,13 +52,52 @@ export DENO_TLS_CA_STORE=system ### Adding New Endpoints -The library is designed to make adding new endpoints extremely easy. Each endpoint follows the same pattern: +The library is designed to make adding new endpoints extremely easy. Each endpoint follows the same +pattern: 1. **Create a new directory** under `src/endpoints/` (e.g., `src/endpoints/people/`) -2. **Define types** in `types.ts` for the endpoint's request/response models -3. **Implement the endpoint class** using `ResourceClient` for HTTP operations in `index.ts` (standard pattern - oauth endpoint is a rare and justified exception) +2. **Define types** in `types.ts` for the endpoint's request/response models Suggested prompt: + ``` + Use the official api documentation from #file:official-documentation.md to draft the types in #file:types.ts in a style that aligns with that of #file:types [<-- point to the groups endpoint types] + ``` +3. **Implement the endpoint class** using `ResourceClient` for HTTP operations in `index.ts` + (standard pattern - oauth endpoint is a rare and justified exception)\ + **Recommended**: Use the `xm-endpoint` VS Code snippet 4. **Export from the main index.ts** to make it available to consumers +#### `xm-endpoint` VS Code Snippet + +A code snippet for kickstarting the content of an endpoint `index.ts` file. + +**Usage:** + +1. In a new TypeScript file, type `xm-endpoint` and press Tab +2. Fill in the placeholders and press Tab + - `$1`: Resource name (PascalCase, e.g., "Person", "Device") + - `$2`: Auto-generated lowercase version for comments + - `$3`: Auto-generated lowercase version for URL path (usually same as $2) + +#### Optional: Validate Against Official Documentation + +As a maintainer, you can test whether the actual xMatters API behaves as documented: + +1. Create a markdown file with the official API documentation for your endpoint +2. Ask an AI to modify `sandbox/validate-docs.ts` to use your new SDK endpoint to make real API + calls +3. Run `deno task sandbox:validate-docs` to discover discrepancies between documentation and actual + API behavior + +This helps you adjust your SDK implementation to work with the real API, not just what the +documentation claims. + +Here is a prompt you could use: + +``` +I'm implementing a new endpoint for an xMatters API SDK. I have the official API documentation for the "/bla" endpoint in [tag .md or .txt file here]. + +Please override the exisint validate-docs.ts file to use our SDK to make real API calls that will reveal any discrepancies between what the documentation claims and how the API actually behaves. +``` + ### Project Structure ``` @@ -79,4 +116,5 @@ src/ └── [new-endpoint]/ # Your new endpoint here ``` -The core abstractions handle all the complex HTTP logic, authentication, retries, and error handling - you just focus on the endpoint-specific business logic. +The core abstractions handle all the complex HTTP logic, authentication, retries, and error +handling - you just focus on the endpoint-specific business logic. diff --git a/README.md b/README.md index 741665b..6eda02d 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ `xmas` for short 🎄 -A TypeScript/JavaScript library for interacting with the xMatt// xmApi will now automatically use OAuth tokens for all subsequent requests -const groups = await xmApi.groups.get(); -``` +A TypeScript/JavaScript library for interacting with the xMatt// xmApi will now automatically use +OAuth tokens for all subsequent requests const groups = await xmApi.groups.get(); -The library will automatically start using the OAuth tokens and purge the username & password from memory for security.PI. +The library will automatically start using the OAuth tokens and purge the username & password from +memory for security.PI. - 🎄 **Zero dependencies** - Uses only native fetch API - 🔒 **Multiple auth methods** - Basic auth, OAuth, and authorization code flow @@ -41,8 +41,8 @@ console.log('Headers:', response.headers); console.log('Created group:', response.body); // Get groups with pagination: -const groupsResponse = await xmApi.groups.get({ - query: { offset: 5, limit: 10 } +const groupsResponse = await xmApi.groups.get({ + query: { offset: 5, limit: 10 }, }); console.log('Total groups:', groupsResponse.body.total); groupsResponse.body.data.forEach((group) => { @@ -99,18 +99,20 @@ const config = { const xmApi = new XmApi(config); // Obtain tokens and automatically transition to OAuth -await xmApi.oauth.obtainTokens({ - clientId: 'your-client-id' +await xmApi.oauth.obtainTokens({ + clientId: 'your-client-id', }); // xmApi will now automatically use OAuth tokens for all subsequent requests const groups = await xmApi.groups.get(); ``` -The library will automatically start using the OAuth tokens and purge the username & password you instantiated it with. +The library will automatically start using the OAuth tokens and purge the username & password you +instantiated it with. ## Dependency injection -The library uses dependency injection to allow you to provide your own implementations for HTTP clients, loggers, and other dependencies. +The library uses dependency injection to allow you to provide your own implementations for HTTP +clients, loggers, and other dependencies. ### Custom HTTP Client @@ -137,28 +139,30 @@ const myHttpClient: HttpClient = { }, }; -// Important: Your HTTP client should NOT throw on HTTP error status codes (4xx, 5xx) -// Instead, return the response normally -// This differs from libraries like Axios that by default throw on error responses -// -// While throwing can feel more natural at first, an HTTP response that contains an error status code is still a response, which is expected behaviour. That is not to say the http client should never throw. -// -// The library aligns with the Fetch API approach because it enables: -// - Better error message formatting with full response context -// - Smarter retry logic -// - Consistent error handling across all HTTP clients (fetch, axios, custom, etc.) - const config = { hostname: 'https://yourOrg.xmatters.com', username: 'authingUserName', password: 'authingUserPassword', httpClient: myHttpClient, }; +// Note: While your HTTP client should not throw on HTTP error status codes (4xx, 5xx), +// the xmApi SDK itself WILL throw XmApiError instances when the xMatters API returns error responses. + +// The library philosophy is that generic HTTP clients should stay simple and predictable, +// while the SDKs leverage their deep knowledge of an API to provide clean +// exception-based error handling in your application code. + +// The HTTP client simply returning the response when it has one, regardless of the status code, +// enables the SDK to inspect HTTP error responses to: +// - Format detailed error messages with full response context +// - Implement smart retry logic (e.g., retry on 429/5xx, refresh tokens on 401) +// - Provide consistent error handling across all HTTP clients ``` ### Custom Logger -The library uses `console` for logging by default, which works well for most applications. You only need to provide a custom logger if you want different behaviors. +The library uses `console` for logging by default, which works well for most applications. You only +need to provide a custom logger if you want different behaviors. **To use your own logging library:** @@ -190,7 +194,8 @@ const myCustomLogger: Logger = { **To silence all logging:** -If you prefer to completely disable logging (rather than configuring log levels in your logging library): +If you prefer to completely disable logging (rather than configuring log levels in your logging +library): ```ts const silentLogger: Logger = { @@ -236,16 +241,16 @@ The `HttpClient` interface that your custom implementation must satisfy: ```ts interface HttpRequest { method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; - url: string; // Fully qualified URL - headers?: Headers; // Key-value pairs of HTTP headers - body?: unknown; // Request body (will be serialized) - retryAttempt?: number; // Current retry attempt for logging + url: string; // Fully qualified URL + headers?: Headers; // Key-value pairs of HTTP headers + body?: unknown; // Request body (will be serialized) + retryAttempt?: number; // Current retry attempt for logging } interface HttpResponse { - body: T; // Parsed response body - status: number; // HTTP status code - headers: Headers; // Response headers + body: T; // Parsed response body + status: number; // HTTP status code + headers: Headers; // Response headers } interface HttpClient { @@ -253,14 +258,20 @@ interface HttpClient { } ``` -**Note:** The `method` field is restricted to the HTTP methods that this library needs to interact with the xMatters API. Your HTTP client implementation can support additional methods (like `OPTIONS`, `HEAD`, etc.) - this restriction only applies to requests that the library will send to your client. +**Note:** The `method` field is restricted to the HTTP methods that this library needs to interact +with the xMatters API. Your HTTP client implementation can support additional methods (like +`OPTIONS`, `HEAD`, etc.) - this restriction only applies to requests that the library will send to +your client. Your HTTP client receives a fully prepared request with: + - Complete URL (including query parameters) - All necessary headers (including authentication) - Serialized request body (if applicable) -The library uses the native `fetch` API by default, so you only need to provide a custom HTTP client if you have specific requirements (like using a different HTTP library or adding custom retry logic). +The library uses the native `fetch` API by default, so you only need to provide a custom HTTP client +if you have specific requirements (like using a different HTTP library or adding custom retry +logic). ## Error Handling @@ -295,23 +306,23 @@ All configuration options: interface XmApiConfig { // Required hostname: string; - + // Authentication (one of these sets required) - username?: string; // Basic auth - password?: string; // Basic auth - authorizationCode?: string; // Auth code flow - accessToken?: string; // OAuth - refreshToken?: string; // OAuth - clientId?: string; // OAuth/Auth code - clientSecret?: string; // Optional for enhanced security - + username?: string; // Basic auth + password?: string; // Basic auth + authorizationCode?: string; // Auth code flow + accessToken?: string; // OAuth + refreshToken?: string; // OAuth + clientId?: string; // OAuth/Auth code + clientSecret?: string; // Optional for enhanced security + // Optional dependencies - httpClient?: HttpClient; // Custom HTTP implementation - logger?: Logger; // Custom logging implementation - + httpClient?: HttpClient; // Custom HTTP implementation + logger?: Logger; // Custom logging implementation + // Optional settings - defaultHeaders?: Headers; // Additional headers for all requests - maxRetries?: number; // Maximum retry attempts + defaultHeaders?: Headers; // Additional headers for all requests + maxRetries?: number; // Maximum retry attempts onTokenRefresh?: TokenRefreshCallback; // Handle token refresh events } ``` diff --git a/src/core/request-builder.ts b/src/core/request-builder.ts index e35e53a..0c3933c 100644 --- a/src/core/request-builder.ts +++ b/src/core/request-builder.ts @@ -1,4 +1,5 @@ import type { Headers, HttpRequest } from './types/internal/http.ts'; +import type { QueryParams } from './types/endpoint/params.ts'; import { XmApiError } from './errors.ts'; /** @@ -25,7 +26,7 @@ export interface RequestBuildOptions { /** Optional headers to send with the request */ headers?: Headers; /** Optional query parameters to include in the URL */ - query?: Record; + query?: QueryParams; /** Optional request body */ body?: unknown; /** Used internally for retry logic */ @@ -42,40 +43,69 @@ export class RequestBuilder { private readonly defaultHeaders: Headers = {}, ) {} + /** + * Builds a query string from query parameters + * @param query The query parameters to add + * @param searchParams Optional URLSearchParams instance to add to (defaults to new instance) + * @returns The query string + */ + private buildQueryString(query: QueryParams, searchParams = new URLSearchParams()): string { + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + if (Array.isArray(value)) { + // Handle arrays by joining with commas + searchParams.set(key, value.map(String).join(',')); + } else { + searchParams.set(key, String(value)); + } + } + }); + return searchParams.toString(); + } + build(options: RequestBuildOptions): HttpRequest { - let url: URL; + let finalUrl: string; if (options.fullUrl && options.path) { throw new XmApiError( 'Cannot specify both fullUrl and path. Use fullUrl for external endpoints, path for xMatters API endpoints.', ); } if (options.fullUrl) { - url = new URL(options.fullUrl); + const url = new URL(options.fullUrl); + if (options.query) { + // Add new query parameters while preserving existing ones + this.buildQueryString(options.query, url.searchParams); + } + finalUrl = url.toString(); } else if (options.path) { if (!options.path.startsWith('/')) { throw new XmApiError('Path must start with a forward slash, e.g. "/people"'); } - url = new URL(`${this.apiVersionPath}${options.path}`, this.baseUrl); + // Start with the base API URL, then manually append the path to preserve encoding + // The xMatters API isn't always consistent in its accepting of + // both encoded and non-encoded identifiers in paths. + // e.g. xm.groups.getByIdentifier('dd comics') === xm.groups.getByIdentifier('dd%20comics') + // but xm.people.getByIdentifier('lol@test.com') !== xm.people.getByIdentifier('lol%40test.com') + // The latter will return a 404 Not Found error. + // So we always use the path as-is, without encoding it. + // This means that the caller must ensure the path is properly encoded. + const url = new URL(this.apiVersionPath, this.baseUrl); + finalUrl = url.toString() + options.path; + // Build out query string from parameters separately using URLSearchParams + if (options.query) { + const queryString = this.buildQueryString(options.query); + if (queryString) { + finalUrl += `?${queryString}`; + } + } } else { throw new XmApiError('Either path or fullUrl must be provided'); } - if (options.query) { - Object.entries(options.query).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - if (Array.isArray(value)) { - // Handle arrays by joining with commas - url.searchParams.set(key, value.map(String).join(',')); - } else { - url.searchParams.set(key, String(value)); - } - } - }); - } // Build headers by merging default headers with request-specific headers const headers: Headers = { ...this.defaultHeaders, ...options.headers }; const builtRequest: HttpRequest = { method: options.method || 'GET', - url: url.toString(), + url: finalUrl, headers, body: options.body, retryAttempt: options.retryAttempt || 0, diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index 7d61700..9f96450 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -162,11 +162,11 @@ Deno.test('GroupsEndpoint', async (t) => { await groups.getByIdentifier('test-group-id'); }); - await t.step('makes GET request with URL-encoded targetName', async () => { + await t.step('makes GET request with targetName containing spaces', async () => { mockHttpClient.setReqRes([{ expectedRequest: { method: 'GET', - url: 'https://test.xmatters.com/api/xm/1/groups/Oracle%20Administrators', + url: 'https://test.xmatters.com/api/xm/1/groups/Oracle Administrators', headers: TestConstants.BASIC_AUTH_HEADERS, }, mockedResponse: { diff --git a/src/endpoints/people/index.test.ts b/src/endpoints/people/index.test.ts new file mode 100644 index 0000000..82be443 --- /dev/null +++ b/src/endpoints/people/index.test.ts @@ -0,0 +1,467 @@ +import { PersonsEndpoint } from './index.ts'; +import { RequestHandler } from '../../core/request-handler.ts'; +import { MockHttpClient, MockLogger, TestConstants } from '../../core/test-utils.ts'; + +// Shared test infrastructure - MockHttpClient auto-resets between tests +const mockHttpClient = new MockHttpClient(); +const mockLogger = new MockLogger(); + +const requestHandler = new RequestHandler({ + httpClient: mockHttpClient, + logger: mockLogger, + ...TestConstants.BASIC_CONFIG, +}); + +const people = new PersonsEndpoint(requestHandler); + +const mockSinglePersonBody = { + id: 'test-person-id', + targetName: 'jsmith', + recipientType: 'PERSON', + status: 'ACTIVE', + firstName: 'John', + lastName: 'Smith', + language: 'en', + timezone: 'US/Eastern', + webLogin: 'jsmith', + externallyOwned: false, + site: { + id: 'site-id', + name: 'Default Site', + links: { + self: '/api/xm/1/sites/site-id', + }, + }, + licenseType: 'FULL_USER', + links: { + self: '/api/xm/1/people/test-person-id', + }, +}; + +const mockPaginatedPeopleBody = { + count: 1, + total: 1, + data: [mockSinglePersonBody], + links: { + self: '/api/xm/1/people?limit=100&offset=0', + }, +}; + +Deno.test('PersonsEndpoint', async (t) => { + await t.step('get() - List People', async (t) => { + await t.step('makes GET request without parameters', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/people', + headers: TestConstants.BASIC_AUTH_HEADERS, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockPaginatedPeopleBody, + }, + }]); + await people.get(); + }); + + await t.step('makes GET request with query parameters', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/people?limit=10&status=ACTIVE', + headers: TestConstants.BASIC_AUTH_HEADERS, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockPaginatedPeopleBody, + }, + }]); + await people.get({ + query: { + limit: 10, + status: 'ACTIVE', + }, + }); + }); + + await t.step('makes GET request with complex query parameters', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: + 'https://test.xmatters.com/api/xm/1/people?search=john+smith&fields=FIRST_NAME%2CLAST_NAME&licenseType=FULL_USER&embed=roles%2Cdevices&sortBy=FIRST_LAST_NAME&sortOrder=ASCENDING', + headers: TestConstants.BASIC_AUTH_HEADERS, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockPaginatedPeopleBody, + }, + }]); + await people.get({ + query: { + search: 'john smith', + fields: ['FIRST_NAME', 'LAST_NAME'], + licenseType: 'FULL_USER', + embed: ['roles', 'devices'], + sortBy: 'FIRST_LAST_NAME', + sortOrder: 'ASCENDING', + }, + }); + }); + + await t.step('makes GET request with custom headers', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/people', + headers: { + ...TestConstants.BASIC_AUTH_HEADERS, + 'X-Custom-Header': 'custom-value', + }, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockPaginatedPeopleBody, + }, + }]); + await people.get({ + headers: { + 'X-Custom-Header': 'custom-value', + }, + }); + }); + + await t.step('makes GET request with array parameters', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: + 'https://test.xmatters.com/api/xm/1/people?groups=group1%2Cgroup2&site=site1%2Csite2&supervisors=super1%2Csuper2', + headers: TestConstants.BASIC_AUTH_HEADERS, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockPaginatedPeopleBody, + }, + }]); + await people.get({ + query: { + groups: ['group1', 'group2'], + site: ['site1', 'site2'], + supervisors: ['super1', 'super2'], + }, + }); + }); + + await t.step('makes GET request with device filter parameters', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: + 'https://test.xmatters.com/api/xm/1/people?devices.exists=true&devices.email.exists=true&devices.status=ACTIVE', + headers: TestConstants.BASIC_AUTH_HEADERS, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockPaginatedPeopleBody, + }, + }]); + await people.get({ + query: { + 'devices.exists': true, + 'devices.email.exists': true, + 'devices.status': 'ACTIVE', + }, + }); + }); + + await t.step('makes GET request with property filter parameters', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: + 'https://test.xmatters.com/api/xm/1/people?propertyNames=department%2Clocation&propertyValues=IT%2CNYC', + headers: TestConstants.BASIC_AUTH_HEADERS, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockPaginatedPeopleBody, + }, + }]); + await people.get({ + query: { + propertyNames: ['department', 'location'], + propertyValues: ['IT', 'NYC'], + }, + }); + }); + }); + + await t.step('getByIdentifier() - Get Single Person', async (t) => { + await t.step('makes GET request with ID', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/people/test-person-id', + headers: TestConstants.BASIC_AUTH_HEADERS, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockSinglePersonBody, + }, + }]); + await people.getByIdentifier('test-person-id'); + }); + + await t.step('makes GET request with targetName containing special characters', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/people/john.smith@example.com', + headers: TestConstants.BASIC_AUTH_HEADERS, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockSinglePersonBody, + }, + }]); + await people.getByIdentifier('john.smith@example.com'); + }); + + await t.step('makes GET request with embed parameters', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/people/test-person-id?embed=roles%2Cdevices', + headers: TestConstants.BASIC_AUTH_HEADERS, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockSinglePersonBody, + }, + }]); + await people.getByIdentifier('test-person-id', { + query: { + embed: ['roles', 'devices'], + }, + }); + }); + + await t.step('makes GET request with custom headers', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'GET', + url: 'https://test.xmatters.com/api/xm/1/people/test-person-id', + headers: { + ...TestConstants.BASIC_AUTH_HEADERS, + 'X-Custom-Header': 'custom-value', + }, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockSinglePersonBody, + }, + }]); + await people.getByIdentifier('test-person-id', { + headers: { + 'X-Custom-Header': 'custom-value', + }, + }); + }); + }); + + await t.step('save() - Create/Update Person', async (t) => { + await t.step('makes POST request for person creation (no id)', async () => { + const newPerson = { + targetName: 'newuser', + firstName: 'New', + lastName: 'User', + recipientType: 'PERSON', + status: 'ACTIVE', + language: 'en', + timezone: 'US/Eastern', + webLogin: 'newuser', + site: { + id: 'site-id', + name: 'Default Site', + links: { + self: '/api/xm/1/sites/site-id', + }, + }, + }; + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/people', + headers: TestConstants.BASIC_AUTH_HEADERS, + body: newPerson, + }, + mockedResponse: { + status: 201, + headers: { 'content-type': 'application/json' }, + body: { ...newPerson, id: 'new-person-id', externallyOwned: false }, + }, + }]); + await people.save(newPerson); + }); + + await t.step('makes POST request for person update (with id)', async () => { + const existingPerson = { + id: 'existing-person-id', + targetName: 'jsmith', + firstName: 'John', + lastName: 'Smith Updated', + recipientType: 'PERSON', + status: 'ACTIVE', + language: 'en', + timezone: 'US/Pacific', + webLogin: 'jsmith', + externallyOwned: false, + site: { + id: 'site-id', + name: 'Default Site', + links: { + self: '/api/xm/1/sites/site-id', + }, + }, + }; + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/people', + headers: TestConstants.BASIC_AUTH_HEADERS, + body: existingPerson, + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: existingPerson, + }, + }]); + await people.save(existingPerson); + }); + + await t.step('makes POST request with minimal person data for creation', async () => { + const minimalPerson = { + targetName: mockSinglePersonBody.targetName, + firstName: mockSinglePersonBody.firstName, + lastName: mockSinglePersonBody.lastName, + }; + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/people', + headers: TestConstants.BASIC_AUTH_HEADERS, + body: minimalPerson, + }, + mockedResponse: { + status: 201, + headers: { 'content-type': 'application/json' }, + body: mockSinglePersonBody, + }, + }]); + await people.save(minimalPerson); + }); + + await t.step('makes POST request with custom headers', async () => { + const newPerson = { + targetName: mockSinglePersonBody.targetName, + firstName: mockSinglePersonBody.firstName, + lastName: mockSinglePersonBody.lastName, + recipientType: mockSinglePersonBody.recipientType, + }; + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/people', + headers: { + ...TestConstants.BASIC_AUTH_HEADERS, + 'X-Custom-Header': 'custom-value', + }, + body: newPerson, + }, + mockedResponse: { + status: 201, + headers: { 'content-type': 'application/json' }, + body: mockSinglePersonBody, + }, + }]); + await people.save(newPerson, { + headers: { + 'X-Custom-Header': 'custom-value', + }, + }); + }); + + await t.step('makes POST request with person properties', async () => { + const personWithProperties = { + targetName: mockSinglePersonBody.targetName, + firstName: mockSinglePersonBody.firstName, + lastName: mockSinglePersonBody.lastName, + properties: { + department: 'Engineering', + location: 'New York', + employeeId: '12345', + }, + }; + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/people', + headers: TestConstants.BASIC_AUTH_HEADERS, + body: personWithProperties, + }, + mockedResponse: { + status: 201, + headers: { 'content-type': 'application/json' }, + body: mockSinglePersonBody, + }, + }]); + await people.save(personWithProperties); + }); + }); + + await t.step('delete() - Delete Person', async (t) => { + await t.step('makes DELETE request with person ID', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'DELETE', + url: 'https://test.xmatters.com/api/xm/1/people/test-person-id', + headers: TestConstants.BASIC_AUTH_HEADERS, + }, + mockedResponse: { status: 204 }, + }]); + await people.delete('test-person-id'); + }); + + await t.step('makes DELETE request with custom headers', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'DELETE', + url: 'https://test.xmatters.com/api/xm/1/people/test-person-id', + headers: { + ...TestConstants.BASIC_AUTH_HEADERS, + 'X-Custom-Header': 'custom-value', + }, + }, + mockedResponse: { status: 204 }, + }]); + await people.delete('test-person-id', { + headers: { + 'X-Custom-Header': 'custom-value', + }, + }); + }); + }); +}); diff --git a/src/endpoints/people/index.ts b/src/endpoints/people/index.ts new file mode 100644 index 0000000..a092a04 --- /dev/null +++ b/src/endpoints/people/index.ts @@ -0,0 +1,84 @@ +import { ResourceClient } from '../../core/resource-client.ts'; +import type { RequestHandler } from '../../core/request-handler.ts'; +import type { HttpResponse } from '../../core/types/internal/http.ts'; +import type { + DeleteOptions, + GetOptions, + RequestWithBodyOptions, +} from '../../core/types/internal/http-methods.ts'; +import type { + EmptyHttpResponse, + PaginatedHttpResponse, +} from '../../core/types/endpoint/response.ts'; +import type { GetPersonParams, GetPersonsParams, GetPersonsResponse, Person } from './types.ts'; + +/** + * Provides access to the people endpoints of the xMatters API. + * Use this class to manage people, including listing, creating, updating, and deleting people. + */ +export class PersonsEndpoint { + private readonly http: ResourceClient; + + constructor(http: RequestHandler) { + this.http = new ResourceClient(http, '/people'); + } + + /** + * Get a list of people from xMatters. + * The results can be filtered and paginated using the options object. + * + * @param options Optional parameters including query filters, headers, and other request options + * @returns The HTTP response containing a paginated list of people + * @throws {XmApiError} If the request fails + */ + get( + options?: Omit & { path?: string; query?: GetPersonsParams }, + ): Promise> { + return this.http.get(options); + } + + /** + * Get a person by its ID or targetName. + * + * @param identifier The ID or targetName of the person to retrieve + * @param options Optional request options including embed parameters and headers + * @returns The HTTP response containing the person + * @throws {XmApiError} If the request fails + */ + getByIdentifier( + identifier: string, + options?: Omit & { query?: GetPersonParams }, + ): Promise> { + return this.http.get({ ...options, path: identifier }); + } + + /** + * Create a new person or update an existing one + * + * @param person The person to create or update + * @param overrides Optional request overrides like custom headers + * @returns The HTTP response containing the created or updated person + * @throws {XmApiError} If the request fails + */ + save( + person: Partial, + overrides?: Omit, + ): Promise> { + return this.http.post({ ...overrides, body: person }); + } + + /** + * Delete a person by ID + * + * @param id The ID of the person to delete + * @param overrides Optional request overrides like custom headers + * @returns The HTTP response + * @throws {XmApiError} If the request fails + */ + delete( + id: string, + overrides?: Omit, + ): Promise { + return this.http.delete({ ...overrides, path: id }); + } +} diff --git a/src/endpoints/people/types.ts b/src/endpoints/people/types.ts new file mode 100644 index 0000000..b24152c --- /dev/null +++ b/src/endpoints/people/types.ts @@ -0,0 +1,327 @@ +import type { PaginatedResponse } from '../../core/types/endpoint/response.ts'; +import type { + PaginationParams, + QueryParams, + SearchParams, + SortOrder, + StatusParams, +} from '../../core/types/endpoint/params.ts'; + +/** + * Represents a person in xMatters. + */ +export interface Person { + /** Unique identifier for the person */ + id: string; + /** The name of the person used for targeting */ + targetName: string; + /** Type of recipient */ + recipientType: + | 'PERSON' + | 'GROUP' + | 'DEVICE' + // deno-lint-ignore ban-types + | (string & {}); // Allows for new recipient types to be used with type assertion + /** Whether the person is active or inactive */ + status: + | 'ACTIVE' + | 'INACTIVE' + // deno-lint-ignore ban-types + | (string & {}); // Allows for new status values to be used with type assertion + /** The person's first name */ + firstName: string; + /** The person's last name */ + lastName: string; + /** Language preference for the person */ + language: string; + /** Timezone setting for the person */ + timezone: string; + /** Web login username */ + webLogin: string; + /** Phone login number for voice authentication */ + phoneLogin?: string; + /** Whether the person is managed by an external system */ + externallyOwned: boolean; + /** Site information for the person */ + site: { + id: string; + name: string; + links: { + self: string; + }; + }; + /** Custom properties and attributes */ + properties?: Record; + /** ISO timestamp of last login */ + lastLogin?: string; + /** Revision information */ + revision?: { + id: string; + at: string; + seq: string; + }; + /** License type for the person */ + licenseType?: + | 'FULL_USER' + | 'STAKEHOLDER_USER' + // deno-lint-ignore ban-types + | (string & {}); // Allows for new license types to be used with type assertion + /** HAL links for the person */ + links?: { + /** URL to this person resource */ + self: string; + }; +} + +/** + * Individual search field options that can be combined + */ +export type PersonSearchField = + | 'FIRST_NAME' + | 'LAST_NAME' + | 'TARGET_NAME' + | 'WEB_LOGIN' + | 'EMAIL_ADDRESS' + | 'PHONE_NUMBER' + // deno-lint-ignore ban-types + | (string & {}); // Allows for new or undocumented search fields to be used with type assertion + +/** + * Type for filters that can be applied when retrieving people. + */ +export interface PersonFilters extends QueryParams { + /** + * Filter records by matching on the exact value of targetName. + * This is case-sensitive and must match the person name exactly. + */ + targetName?: string; + + /** + * Defines the field to search when a search term is specified. + * Can specify individual fields or arrays of fields to search. + */ + fields?: PersonSearchField | PersonSearchField[]; + + /** + * Returns a list of people created after the provided timestamp (in ISO format). + * Can be used on its own or in conjunction with createdBefore and createdTo. + */ + createdAfter?: string; + + /** + * Returns a list of people created before (and excluding) the provided timestamp (in ISO format). + * Can be used on its own or in conjunction with createdAfter and createdFrom. + */ + createdBefore?: string; + + /** + * Returns a list of people created from the provided timestamp (in ISO format). + * Can be used on its own or in conjunction with createdTo and createdBefore. + */ + createdFrom?: string; + + /** + * Returns a list of people created up to (and including) the provided timestamp (in ISO format). + * Can be used on its own or in conjunction with createdFrom and createdAfter. + */ + createdTo?: string; + + /** + * Returns a list of users who have (or don't have) devices associated with their account. + */ + 'devices.exists'?: boolean; + + /** + * Returns a list of users who have (or don't have) email devices associated with their account. + */ + 'devices.email.exists'?: boolean; + + /** + * Returns a list of users who have (or don't have) failsafe devices associated with their account. + */ + 'devices.failsafe.exists'?: boolean; + + /** + * Returns a list of users who have (or don't have) devices with the xMatters mobile app associated with their account. + */ + 'devices.mobile.exists'?: boolean; + + /** + * Returns a list of users who have (or don't have) SMS devices associated with their account. + */ + 'devices.sms.exists'?: boolean; + + /** + * Returns a list of users who have (or don't have) voice devices associated with their account. + */ + 'devices.voice.exists'?: boolean; + + /** + * Returns a list of devices for each user and includes whether each device is active or inactive. + */ + 'devices.status'?: + | 'ACTIVE' + | 'INACTIVE' + // deno-lint-ignore ban-types + | (string & {}); // Allows for new device status values to be used with type assertion + + /** + * Returns a list of devices for each user and includes whether each device was successfully tested or not. + */ + 'devices.testStatus'?: + | 'INVALID' + | 'TESTED' + | 'UNTESTED' + | 'UNTESTED_FAILSAFE' + | 'PENDING' + // deno-lint-ignore ban-types + | (string & {}); // Allows for new test status values to be used with type assertion + + /** + * The valid email address of the user. + * Can be combined with propertyNames, propertyValues to further narrow your search results. + */ + emailAddress?: string; + + /** + * The first name of the user. + * Can be combined with propertyNames, propertyValues to further narrow your search results. + */ + firstName?: string; + + /** + * The last name of the user. + * Can be combined with propertyNames, propertyValues to further narrow your search results. + */ + lastName?: string; + + /** + * A comma-separated list of group target names or UUIDs. + * When multiple groups are specified, the results return users who are members of any of the specified groups. + */ + groups?: string | string[]; + + /** + * Returns a list of users who have (or don't have) groups associated with their account. + */ + 'groups.exists'?: boolean; + + /** + * Filter by license type. + */ + licenseType?: + | 'FULL_USER' + | 'STAKEHOLDER_USER' + // deno-lint-ignore ban-types + | (string & {}); // Allows for new license types to be used with type assertion + + /** + * The phone number of the user. + * Can be combined with propertyNames, propertyValues to further narrow your search results. + */ + phoneNumber?: string; + + /** + * A comma-separated list of custom field/attribute names to search for. + * Must be used in conjunction with propertyValues. + */ + propertyNames?: string | string[]; + + /** + * A comma-separated list of custom field/attribute values to search for. + * Must be used in conjunction with propertyNames. + */ + propertyValues?: string | string[]; + + /** + * A comma-separated list of role names to filter by. + * Returns users who have any of the specified roles. + */ + roles?: string | string[]; + + /** + * A comma-separated list of sites whose people you want to retrieve. + * You can specify the site using its unique identifier (id) or name (case-insensitive), or both. + */ + site?: string | string[]; + + /** + * A comma-separated list of supervisors whose people you want to retrieve. + * You can specify the supervisors using targetName (case-insensitive) or id. + */ + supervisors?: string | string[]; + + /** + * Returns a list of users who have (or don't have) supervisors associated with their account. + */ + 'supervisors.exists'?: boolean; + + /** + * The web login name of the user. + */ + webLogin?: string; +} + +/** + * Person-specific sort parameters + */ +export interface PersonSortParams { + /** + * Field to sort by + */ + sortBy?: + | 'FIRST_LAST_NAME' + | 'LAST_FIRST_NAME' + | 'TARGET_NAME' + | 'CREATED' + | 'LAST_LOGIN' + // deno-lint-ignore ban-types + | (string & {}); // Allows for new sort fields to be used with type assertion + + /** + * Sort direction + * @default 'ASCENDING' + */ + sortOrder?: SortOrder; +} + +/** + * Supported embed values for retrieving people. + * These apply to both single person and multiple people endpoints. + */ +export type PersonEmbedOptions = + | 'roles' // includes the person's roles in the result + | 'supervisors' // includes the person's supervisors in the result + | 'devices' // includes a list of each person's devices + // deno-lint-ignore ban-types + | (string & {}); // Allows for new or undocumented embed options to be used with type assertion + +/** + * Type for parameters used when retrieving a single person by identifier. + * Supports embedding related objects in the response. + */ +export interface GetPersonParams extends Record { + /** + * Objects to embed in the response. Can be a single value or an array of values. + * For new/undocumented embed options, use type assertion: 'newOption' as PersonEmbedOptions or any + */ + embed?: PersonEmbedOptions | PersonEmbedOptions[]; +} + +/** + * Type for parameters used in methods that retrieve lists of people. + * Combines common pagination, search, status, sort, and person-specific filters and embed options. + */ +export type GetPersonsParams = + & PaginationParams + & SearchParams + & StatusParams + & PersonFilters + & PersonSortParams + & GetPersonParams; + +/** + * Response type for methods that return a list of people. + * This is a paginated response containing an array of Person objects. + */ +export type GetPersonsResponse = PaginatedResponse; diff --git a/src/index.ts b/src/index.ts index 9dd8b64..08faf8e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { validateConfig } from './core/utils/index.ts'; import type { XmApiConfig } from './core/types/internal/config.ts'; import { GroupsEndpoint } from './endpoints/groups/index.ts'; import { OAuthEndpoint } from './endpoints/oauth/index.ts'; +import { PersonsEndpoint } from './endpoints/people/index.ts'; /** * Main entry point for the xMatters API client. @@ -11,10 +12,9 @@ import { OAuthEndpoint } from './endpoints/oauth/index.ts'; export class XmApi { /** HTTP handler that manages all API requests */ private readonly http: RequestHandler; - /** Access groups-related endpoints */ public readonly groups: GroupsEndpoint; - /** Access OAuth-related endpoints for token acquisition */ public readonly oauth: OAuthEndpoint; + public readonly people: PersonsEndpoint; constructor(config: XmApiConfig) { // Validate config to ensure it's in exactly one valid state @@ -23,6 +23,7 @@ export class XmApi { // Initialize endpoints this.groups = new GroupsEndpoint(this.http); this.oauth = new OAuthEndpoint(this.http); + this.people = new PersonsEndpoint(this.http); } } From a6f9f210b782fd093c33dc0854dfcdd9538ebe34 Mon Sep 17 00:00:00 2001 From: johan Date: Sat, 28 Jun 2025 18:23:21 -0700 Subject: [PATCH 080/101] Implement Axios adapter --- README.md | 33 +++++++++++ src/core/defaults/axios-http-client.ts | 76 ++++++++++++++++++++++++++ src/core/defaults/index.ts | 1 + src/core/types/internal/http.ts | 19 +++++++ src/index.ts | 2 + 5 files changed, 131 insertions(+) create mode 100644 src/core/defaults/axios-http-client.ts diff --git a/README.md b/README.md index 6eda02d..a7c29a9 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,39 @@ clients, loggers, and other dependencies. If you want to use your own HTTP client implementation: +#### Using the Built-in Axios Adapter + +For projects that already use axios, you can use the provided adapter function: + +```ts +import axios from 'axios'; +import { XmApi, createAxiosAdapter } from '@johanfive/xmas'; + +// Create your axios instance with whatever config you need +const axiosInstance = axios.create({ + timeout: 10000, + proxy: { + host: 'proxy.company.com', + port: 8080, + }, +}); + +const config = { + hostname: 'https://yourOrg.xmatters.com', + username: 'authingUserName', + password: 'authingUserPassword', + httpClient: createAxiosAdapter(axiosInstance), +}; + +const xmApi = new XmApi(config); +``` + +> **Note:** Only use this if your project already uses axios. Otherwise, the default HTTP client (fetch) works great with zero dependencies. + +#### Custom Implementation + +For more advanced use cases, you can implement your own HTTP client: + ```ts import type { HttpClient, HttpRequest, HttpResponse } from '@johanfive/xmas'; diff --git a/src/core/defaults/axios-http-client.ts b/src/core/defaults/axios-http-client.ts new file mode 100644 index 0000000..20c7d96 --- /dev/null +++ b/src/core/defaults/axios-http-client.ts @@ -0,0 +1,76 @@ +import type { Headers, HttpClient, HttpRequest, HttpResponse } from '../types/internal/http.ts'; + +// Minimal interface for what we need from an axios instance +interface AxiosLike { + (config: { + url: string; + method: string; + headers?: Record; + data?: unknown; + validateStatus?: () => boolean; + }): Promise<{ + status: number; + headers: Record; + data: unknown; + }>; +} + +/** + * Creates an HTTP client adapter from an existing axios instance. + * + * **Use this adapter ONLY if your project already uses axios.** + * + * This function wraps your existing axios instance to work with the xMatters API library. + * It ensures axios doesn't throw on HTTP error status codes, which would interfere + * with the library's retry and error handling logic. + * + * If your project doesn't already use axios, stick with the default HTTP client + * (which uses native fetch) - no additional dependencies needed. + * + * ## Usage + * + * ```typescript + * import axios from 'axios'; + * import { createAxiosAdapter, XmApi } from 'xmas'; + * + * // Create your axios instance with whatever config you need + * const axiosInstance = axios.create({ + * timeout: 10000, + * proxy: { host: 'proxy.company.com', port: 8080 }, + * }); + * + * const client = new XmApi({ + * hostname: 'your-instance.xmatters.com', + * username: 'your-username', + * password: 'your-password', + * httpClient: createAxiosAdapter(axiosInstance), + * }); + * ``` + * + * @param axiosInstance - Your existing axios instance + * @returns HttpClient that wraps the axios instance + */ +export function createAxiosAdapter(axiosInstance: AxiosLike): HttpClient { + return { + async send(request: HttpRequest): Promise { + const response = await axiosInstance({ + url: request.url, + method: request.method, + headers: request.headers, + data: request.body, + validateStatus: () => true, // Critical: never throw on HTTP status codes + }); + + const headers: Headers = {}; + Object.entries(response.headers).forEach(([key, value]) => { + headers[key.toLowerCase()] = String(value); + }); + + return { + status: response.status, + headers, + body: response.data, + }; + }, + }; +} diff --git a/src/core/defaults/index.ts b/src/core/defaults/index.ts index 2c7424e..905d5af 100644 --- a/src/core/defaults/index.ts +++ b/src/core/defaults/index.ts @@ -1,2 +1,3 @@ export { DefaultHttpClient } from './http-client.ts'; +export { createAxiosAdapter } from './axios-http-client.ts'; export { defaultLogger } from './logger.ts'; diff --git a/src/core/types/internal/http.ts b/src/core/types/internal/http.ts index dfcca59..16b001f 100644 --- a/src/core/types/internal/http.ts +++ b/src/core/types/internal/http.ts @@ -41,8 +41,27 @@ export interface HttpRequest { /** * Interface that HTTP clients must implement to be used with this library. + * * This allows consumers to inject their own HTTP implementation. + * + * HTTP client implementations **MUST**: + * - Return responses for all HTTP status codes (do NOT throw on 4xx/5xx errors) + * - Handle redirects (3xx) according to HTTP standards (typically automatically) + * - Normalize response headers to lowercase keys for consistency + * - Parse JSON response bodies when Content-Type indicates JSON + * + * Network/connectivity errors (DNS resolution, connection refused, timeouts, etc.) + * should be allowed to bubble up as-is - the library will catch and wrap them in + * XmApiError instances with appropriate context. */ export interface HttpClient { + /** + * Sends an HTTP request and returns the response. + * + * @param request - The HTTP request to send + * @returns Promise that resolves to the HTTP response + * @throws May throw for network/connectivity errors (DNS, connection, timeout, etc.) + * but MUST NOT throw for HTTP error status codes (4xx, 5xx) + */ send: (request: HttpRequest) => Promise; } diff --git a/src/index.ts b/src/index.ts index 08faf8e..973d1fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,3 +33,5 @@ export type { Logger, TokenRefreshCallback } from './core/types/internal/config. export type { HttpClient } from './core/types/internal/http.ts'; // Export error class - consumers need to catch and handle these export { XmApiError } from './core/errors.ts'; +// For convenience +export { createAxiosAdapter } from './core/defaults/index.ts'; From 37d3c0b13ea9b9fe3eb599fe0364220a7cf55192 Mon Sep 17 00:00:00 2001 From: johan Date: Sat, 28 Jun 2025 18:24:31 -0700 Subject: [PATCH 081/101] deno fmt --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a7c29a9..d47c173 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ For projects that already use axios, you can use the provided adapter function: ```ts import axios from 'axios'; -import { XmApi, createAxiosAdapter } from '@johanfive/xmas'; +import { createAxiosAdapter, XmApi } from '@johanfive/xmas'; // Create your axios instance with whatever config you need const axiosInstance = axios.create({ @@ -145,7 +145,8 @@ const config = { const xmApi = new XmApi(config); ``` -> **Note:** Only use this if your project already uses axios. Otherwise, the default HTTP client (fetch) works great with zero dependencies. +> **Note:** Only use this if your project already uses axios. Otherwise, the default HTTP client +> (fetch) works great with zero dependencies. #### Custom Implementation From 4f1e0daac67d3d6bfefddde58a7496e3abdead36 Mon Sep 17 00:00:00 2001 From: johan Date: Tue, 1 Jul 2025 16:18:33 -0700 Subject: [PATCH 082/101] Fix how return types are defined in endpoints Add more methods to groups Renamed AxiosAdapter --- .vscode/typescript.code-snippets | 29 +++--------- src/core/defaults/axios-http-client.ts | 6 +-- src/core/defaults/index.ts | 2 +- src/core/types/endpoint/response.ts | 37 --------------- src/endpoints/groups/index.ts | 62 ++++++++++++++++++-------- src/endpoints/groups/types.ts | 13 ++++-- src/endpoints/oauth/index.ts | 9 ++-- src/endpoints/people/index.ts | 18 +++----- src/endpoints/people/types.ts | 7 --- src/index.ts | 2 +- 10 files changed, 75 insertions(+), 110 deletions(-) diff --git a/.vscode/typescript.code-snippets b/.vscode/typescript.code-snippets index 78fd85e..de73099 100644 --- a/.vscode/typescript.code-snippets +++ b/.vscode/typescript.code-snippets @@ -5,17 +5,13 @@ "body": [ "import { ResourceClient } from '../../core/resource-client.ts';", "import type { RequestHandler } from '../../core/request-handler.ts';", - "import type { HttpResponse } from '../../core/types/internal/http.ts';", + "import type { PaginatedResponse } from '../../core/types/endpoint/response.ts';", "import type {", " DeleteOptions,", " GetOptions,", " RequestWithBodyOptions,", "} from '../../core/types/internal/http-methods.ts';", - "import type {", - " EmptyHttpResponse,", - " PaginatedHttpResponse,", - "} from '../../core/types/endpoint/response.ts';", - "import type { Get${1:Resource}Params, Get${1:Resource}sParams, Get${1:Resource}sResponse, ${1:Resource} } from './types.ts';", + "import type { Get${1:Resource}Params, Get${1:Resource}sParams, ${1:Resource} } from './types.ts';", "", "/**", " * Provides access to the ${2:${1/(.*)/${1:/downcase}/}} endpoints of the xMatters API.", @@ -36,10 +32,8 @@ " * @returns The HTTP response containing a paginated list of ${2:${1/(.*)/${1:/downcase}/}}s", " * @throws {XmApiError} If the request fails", " */", - " get(", - " options?: Omit & { path?: string; query?: Get${1:Resource}sParams },", - " ): Promise> {", - " return this.http.get(options);", + " get(options?: Omit & { path?: string; query?: Get${1:Resource}sParams }) {", + " return this.http.get>(options);", " }", "", " /**", @@ -50,10 +44,7 @@ " * @returns The HTTP response containing the ${2:${1/(.*)/${1:/downcase}/}}", " * @throws {XmApiError} If the request fails", " */", - " getByIdentifier(", - " identifier: string,", - " options?: Omit & { query?: Get${1:Resource}Params },", - " ): Promise> {", + " getByIdentifier(identifier: string, options?: Omit & { query?: Get${1:Resource}Params }) {", " return this.http.get<${1:Resource}>({ ...options, path: identifier });", " }", "", @@ -65,10 +56,7 @@ " * @returns The HTTP response containing the created or updated ${2:${1/(.*)/${1:/downcase}/}}", " * @throws {XmApiError} If the request fails", " */", - " save(", - " ${2:${1/(.*)/${1:/downcase}/}}: Partial<${1:Resource}>,", - " overrides?: Omit,", - " ): Promise> {", + " save(${2:${1/(.*)/${1:/downcase}/}}: Partial<${1:Resource}>, overrides?: Omit) {", " return this.http.post<${1:Resource}>({ ...overrides, body: ${2:${1/(.*)/${1:/downcase}/}} });", " }", "", @@ -80,10 +68,7 @@ " * @returns The HTTP response", " * @throws {XmApiError} If the request fails", " */", - " delete(", - " id: string,", - " overrides?: Omit,", - " ): Promise {", + " delete(id: string, overrides?: Omit) {", " return this.http.delete({ ...overrides, path: id });", " }", "}", diff --git a/src/core/defaults/axios-http-client.ts b/src/core/defaults/axios-http-client.ts index 20c7d96..e3da1ff 100644 --- a/src/core/defaults/axios-http-client.ts +++ b/src/core/defaults/axios-http-client.ts @@ -31,7 +31,7 @@ interface AxiosLike { * * ```typescript * import axios from 'axios'; - * import { createAxiosAdapter, XmApi } from 'xmas'; + * import { axiosAdapter, XmApi } from 'xmas'; * * // Create your axios instance with whatever config you need * const axiosInstance = axios.create({ @@ -43,14 +43,14 @@ interface AxiosLike { * hostname: 'your-instance.xmatters.com', * username: 'your-username', * password: 'your-password', - * httpClient: createAxiosAdapter(axiosInstance), + * httpClient: axiosAdapter(axiosInstance), * }); * ``` * * @param axiosInstance - Your existing axios instance * @returns HttpClient that wraps the axios instance */ -export function createAxiosAdapter(axiosInstance: AxiosLike): HttpClient { +export function axiosAdapter(axiosInstance: AxiosLike): HttpClient { return { async send(request: HttpRequest): Promise { const response = await axiosInstance({ diff --git a/src/core/defaults/index.ts b/src/core/defaults/index.ts index 905d5af..4777d50 100644 --- a/src/core/defaults/index.ts +++ b/src/core/defaults/index.ts @@ -1,3 +1,3 @@ export { DefaultHttpClient } from './http-client.ts'; -export { createAxiosAdapter } from './axios-http-client.ts'; +export { axiosAdapter } from './axios-http-client.ts'; export { defaultLogger } from './logger.ts'; diff --git a/src/core/types/endpoint/response.ts b/src/core/types/endpoint/response.ts index 5764883..810117d 100644 --- a/src/core/types/endpoint/response.ts +++ b/src/core/types/endpoint/response.ts @@ -3,8 +3,6 @@ * These provide standardized response shapes that endpoints can use. */ -import type { HttpResponse } from '../internal/http.ts'; - /** * Common response wrapper for paginated lists */ @@ -25,38 +23,3 @@ export interface PaginatedResponse { prev?: string; }; } - -/** - * Type alias for HTTP responses containing paginated data. - * Use this for endpoint methods that return paginated lists. - * - * @template T The type of items in the paginated response - * - * @example - * ```typescript - * // Instead of: Promise> - * // Use: Promise> - * get(): Promise> { - * return this.http.get>({ path: '/groups' }); - * } - * ``` - */ -export type PaginatedHttpResponse = HttpResponse>; - -// Note: For single resource responses, use HttpResponse directly -// Example: Promise> instead of creating an unnecessary alias - -/** - * Type alias for HTTP responses that don't return a body (like delete operations). - * Use this for endpoint methods that perform actions without returning data. - * - * @example - * ```typescript - * // Instead of: Promise - * // Use: Promise - * delete(id: string): Promise { - * return this.http.delete({ path: `/${id}` }); - * } - * ``` - */ -export type EmptyHttpResponse = HttpResponse; diff --git a/src/endpoints/groups/index.ts b/src/endpoints/groups/index.ts index b259e06..079a370 100644 --- a/src/endpoints/groups/index.ts +++ b/src/endpoints/groups/index.ts @@ -1,16 +1,13 @@ import { ResourceClient } from '../../core/resource-client.ts'; import type { RequestHandler } from '../../core/request-handler.ts'; -import type { HttpResponse } from '../../core/types/internal/http.ts'; +import type { PaginatedResponse } from '../../core/types/endpoint/response.ts'; import type { DeleteOptions, GetOptions, RequestWithBodyOptions, } from '../../core/types/internal/http-methods.ts'; -import type { - EmptyHttpResponse, - PaginatedHttpResponse, -} from '../../core/types/endpoint/response.ts'; -import type { GetGroupParams, GetGroupsParams, GetGroupsResponse, Group } from './types.ts'; +import type { GetGroupParams, GetGroupsParams, Group, GroupQuotas } from './types.ts'; +import type { Person } from '../people/types.ts'; /** * Provides access to the groups endpoints of the xMatters API. @@ -31,10 +28,43 @@ export class GroupsEndpoint { * @returns The HTTP response containing a paginated list of groups * @throws {XmApiError} If the request fails */ - get( - options?: Omit & { path?: string; query?: GetGroupsParams }, - ): Promise> { - return this.http.get(options); + get(options?: Omit & { path?: string; query?: GetGroupsParams }) { + return this.http.get>(options); + } + + /** + * Get a paginated list of supervisors for a group by its ID or targetName. + * + * @param groupId The ID or targetName of the group + * @param options Optional request options (query, headers, etc) + * @returns The HTTP response containing a paginated list of supervisors (Person objects) + * @throws {XmApiError} If the request fails + */ + getSupervisors(groupId: string, options?: Omit) { + return this.http.get>({ ...options, path: `${groupId}/supervisors` }); + } + + /** + * Get a paginated list of recipients for a group by its ID or targetName. + * + * @param groupId The ID or targetName of the group + * @param options Optional request options (query, headers, etc) + * @returns The HTTP response containing a paginated list of recipients (Person objects) + * @throws {XmApiError} If the request fails + */ + getRecipients(groupId: string, options?: Omit) { + return this.http.get>({ ...options, path: `${groupId}/recipients` }); + } + + /** + * Get the group license quotas for your company. + * + * @param options Optional request options (headers, etc) + * @returns The HTTP response containing the group license quotas + * @throws {XmApiError} If the request fails + */ + getLicenseQuotas(options?: Omit) { + return this.http.get({ ...options, path: 'license-quotas' }); } /** @@ -48,7 +78,7 @@ export class GroupsEndpoint { getByIdentifier( identifier: string, options?: Omit & { query?: GetGroupParams }, - ): Promise> { + ) { return this.http.get({ ...options, path: identifier }); } @@ -60,10 +90,7 @@ export class GroupsEndpoint { * @returns The HTTP response containing the created or updated group * @throws {XmApiError} If the request fails */ - save( - group: Partial, - overrides?: Omit, - ): Promise> { + save(group: Partial, overrides?: Omit) { return this.http.post({ ...overrides, body: group }); } @@ -75,10 +102,7 @@ export class GroupsEndpoint { * @returns The HTTP response * @throws {XmApiError} If the request fails */ - delete( - id: string, - overrides?: Omit, - ): Promise { + delete(id: string, overrides?: Omit) { return this.http.delete({ ...overrides, path: id }); } } diff --git a/src/endpoints/groups/types.ts b/src/endpoints/groups/types.ts index bab4b84..0828d3a 100644 --- a/src/endpoints/groups/types.ts +++ b/src/endpoints/groups/types.ts @@ -1,4 +1,3 @@ -import type { PaginatedResponse } from '../../core/types/endpoint/response.ts'; import type { PaginationParams, QueryParams, @@ -209,7 +208,13 @@ export type GetGroupsParams = & GetGroupParams; /** - * Response type for methods that return a list of groups. - * This is a paginated response containing an array of Group objects. + * Group quotas object returned by /groups/license-quotas */ -export type GetGroupsResponse = PaginatedResponse; +export interface GroupQuotas { + groupQuotaEnabled: boolean; + groups: { + total: number; + active: number; + unused: number; + }; +} diff --git a/src/endpoints/oauth/index.ts b/src/endpoints/oauth/index.ts index 6327fc6..641e4a0 100644 --- a/src/endpoints/oauth/index.ts +++ b/src/endpoints/oauth/index.ts @@ -1,6 +1,5 @@ import type { RequestHandler } from '../../core/request-handler.ts'; import type { OAuth2TokenResponse } from '../../core/types/internal/oauth.ts'; -import type { HttpResponse } from '../../core/types/internal/http.ts'; import { XmApiError } from '../../core/errors.ts'; export class OAuthEndpoint { @@ -21,7 +20,7 @@ export class OAuthEndpoint { */ async obtainTokens( options: { clientId?: string; clientSecret?: string } = {}, - ): Promise> { + ) { const { clientId, clientSecret } = options; const authState = this.http.getCurrentAuthState(); switch (authState.type) { @@ -62,7 +61,7 @@ export class OAuthEndpoint { */ private async getOAuthToken( options: { payload: string; clientId: string }, - ): Promise> { + ) { const { payload, clientId } = options; const response = await this.http.post({ path: '/oauth2/token', @@ -95,7 +94,7 @@ export class OAuthEndpoint { clientId?: string; clientSecret?: string; }, - ): Promise> { + ) { const { username, password, clientId, clientSecret } = options; if (!clientId) { throw new XmApiError( @@ -128,7 +127,7 @@ export class OAuthEndpoint { clientId: string; clientSecret?: string; }, - ): Promise> { + ) { const { authorizationCode, clientId, clientSecret } = options; const payload = this.buildFormData({ grant_type: 'authorization_code', diff --git a/src/endpoints/people/index.ts b/src/endpoints/people/index.ts index a092a04..9d9f4e1 100644 --- a/src/endpoints/people/index.ts +++ b/src/endpoints/people/index.ts @@ -1,16 +1,12 @@ import { ResourceClient } from '../../core/resource-client.ts'; import type { RequestHandler } from '../../core/request-handler.ts'; -import type { HttpResponse } from '../../core/types/internal/http.ts'; +import type { PaginatedResponse } from '../../core/types/endpoint/response.ts'; import type { DeleteOptions, GetOptions, RequestWithBodyOptions, } from '../../core/types/internal/http-methods.ts'; -import type { - EmptyHttpResponse, - PaginatedHttpResponse, -} from '../../core/types/endpoint/response.ts'; -import type { GetPersonParams, GetPersonsParams, GetPersonsResponse, Person } from './types.ts'; +import type { GetPersonParams, GetPersonsParams, Person } from './types.ts'; /** * Provides access to the people endpoints of the xMatters API. @@ -33,8 +29,8 @@ export class PersonsEndpoint { */ get( options?: Omit & { path?: string; query?: GetPersonsParams }, - ): Promise> { - return this.http.get(options); + ) { + return this.http.get>(options); } /** @@ -48,7 +44,7 @@ export class PersonsEndpoint { getByIdentifier( identifier: string, options?: Omit & { query?: GetPersonParams }, - ): Promise> { + ) { return this.http.get({ ...options, path: identifier }); } @@ -63,7 +59,7 @@ export class PersonsEndpoint { save( person: Partial, overrides?: Omit, - ): Promise> { + ) { return this.http.post({ ...overrides, body: person }); } @@ -78,7 +74,7 @@ export class PersonsEndpoint { delete( id: string, overrides?: Omit, - ): Promise { + ) { return this.http.delete({ ...overrides, path: id }); } } diff --git a/src/endpoints/people/types.ts b/src/endpoints/people/types.ts index b24152c..19c2afa 100644 --- a/src/endpoints/people/types.ts +++ b/src/endpoints/people/types.ts @@ -1,4 +1,3 @@ -import type { PaginatedResponse } from '../../core/types/endpoint/response.ts'; import type { PaginationParams, QueryParams, @@ -319,9 +318,3 @@ export type GetPersonsParams = & PersonFilters & PersonSortParams & GetPersonParams; - -/** - * Response type for methods that return a list of people. - * This is a paginated response containing an array of Person objects. - */ -export type GetPersonsResponse = PaginatedResponse; diff --git a/src/index.ts b/src/index.ts index 973d1fb..89e5108 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,4 +34,4 @@ export type { HttpClient } from './core/types/internal/http.ts'; // Export error class - consumers need to catch and handle these export { XmApiError } from './core/errors.ts'; // For convenience -export { createAxiosAdapter } from './core/defaults/index.ts'; +export { axiosAdapter } from './core/defaults/index.ts'; From 3cb6f7c0740217dc9b5651d9b7ff244827fbd45b Mon Sep 17 00:00:00 2001 From: johan Date: Tue, 1 Jul 2025 18:31:06 -0700 Subject: [PATCH 083/101] Readd explicit return types because of deno default linting rules (allegedly ts is slower without explicit return types) Better specify types for Create vs Update scenarios of /people --- src/core/types/endpoint/response.ts | 37 +++++++++++++++++++++++++++++ src/endpoints/groups/index.ts | 33 ++++++++++++++++++------- src/endpoints/oauth/index.ts | 3 ++- src/endpoints/people/index.test.ts | 4 ++++ src/endpoints/people/index.ts | 25 +++++++++++++------ src/endpoints/people/types.ts | 15 ++++++++++++ 6 files changed, 100 insertions(+), 17 deletions(-) diff --git a/src/core/types/endpoint/response.ts b/src/core/types/endpoint/response.ts index 810117d..5764883 100644 --- a/src/core/types/endpoint/response.ts +++ b/src/core/types/endpoint/response.ts @@ -3,6 +3,8 @@ * These provide standardized response shapes that endpoints can use. */ +import type { HttpResponse } from '../internal/http.ts'; + /** * Common response wrapper for paginated lists */ @@ -23,3 +25,38 @@ export interface PaginatedResponse { prev?: string; }; } + +/** + * Type alias for HTTP responses containing paginated data. + * Use this for endpoint methods that return paginated lists. + * + * @template T The type of items in the paginated response + * + * @example + * ```typescript + * // Instead of: Promise> + * // Use: Promise> + * get(): Promise> { + * return this.http.get>({ path: '/groups' }); + * } + * ``` + */ +export type PaginatedHttpResponse = HttpResponse>; + +// Note: For single resource responses, use HttpResponse directly +// Example: Promise> instead of creating an unnecessary alias + +/** + * Type alias for HTTP responses that don't return a body (like delete operations). + * Use this for endpoint methods that perform actions without returning data. + * + * @example + * ```typescript + * // Instead of: Promise + * // Use: Promise + * delete(id: string): Promise { + * return this.http.delete({ path: `/${id}` }); + * } + * ``` + */ +export type EmptyHttpResponse = HttpResponse; diff --git a/src/endpoints/groups/index.ts b/src/endpoints/groups/index.ts index 079a370..b7f15a7 100644 --- a/src/endpoints/groups/index.ts +++ b/src/endpoints/groups/index.ts @@ -1,6 +1,10 @@ import { ResourceClient } from '../../core/resource-client.ts'; import type { RequestHandler } from '../../core/request-handler.ts'; -import type { PaginatedResponse } from '../../core/types/endpoint/response.ts'; +import type { HttpResponse } from '../../core/types/internal/http.ts'; +import type { + PaginatedHttpResponse, + PaginatedResponse, +} from '../../core/types/endpoint/response.ts'; import type { DeleteOptions, GetOptions, @@ -28,7 +32,9 @@ export class GroupsEndpoint { * @returns The HTTP response containing a paginated list of groups * @throws {XmApiError} If the request fails */ - get(options?: Omit & { path?: string; query?: GetGroupsParams }) { + get( + options?: Omit & { path?: string; query?: GetGroupsParams }, + ): Promise> { return this.http.get>(options); } @@ -40,7 +46,10 @@ export class GroupsEndpoint { * @returns The HTTP response containing a paginated list of supervisors (Person objects) * @throws {XmApiError} If the request fails */ - getSupervisors(groupId: string, options?: Omit) { + getSupervisors( + groupId: string, + options?: Omit, + ): Promise> { return this.http.get>({ ...options, path: `${groupId}/supervisors` }); } @@ -52,7 +61,10 @@ export class GroupsEndpoint { * @returns The HTTP response containing a paginated list of recipients (Person objects) * @throws {XmApiError} If the request fails */ - getRecipients(groupId: string, options?: Omit) { + getRecipients( + groupId: string, + options?: Omit, + ): Promise> { return this.http.get>({ ...options, path: `${groupId}/recipients` }); } @@ -63,7 +75,7 @@ export class GroupsEndpoint { * @returns The HTTP response containing the group license quotas * @throws {XmApiError} If the request fails */ - getLicenseQuotas(options?: Omit) { + getLicenseQuotas(options?: Omit): Promise> { return this.http.get({ ...options, path: 'license-quotas' }); } @@ -78,7 +90,7 @@ export class GroupsEndpoint { getByIdentifier( identifier: string, options?: Omit & { query?: GetGroupParams }, - ) { + ): Promise> { return this.http.get({ ...options, path: identifier }); } @@ -90,7 +102,10 @@ export class GroupsEndpoint { * @returns The HTTP response containing the created or updated group * @throws {XmApiError} If the request fails */ - save(group: Partial, overrides?: Omit) { + save( + group: Partial, + overrides?: Omit, + ): Promise> { return this.http.post({ ...overrides, body: group }); } @@ -102,7 +117,7 @@ export class GroupsEndpoint { * @returns The HTTP response * @throws {XmApiError} If the request fails */ - delete(id: string, overrides?: Omit) { - return this.http.delete({ ...overrides, path: id }); + delete(id: string, overrides?: Omit): Promise> { + return this.http.delete({ ...overrides, path: id }); } } diff --git a/src/endpoints/oauth/index.ts b/src/endpoints/oauth/index.ts index 641e4a0..58941c4 100644 --- a/src/endpoints/oauth/index.ts +++ b/src/endpoints/oauth/index.ts @@ -1,4 +1,5 @@ import type { RequestHandler } from '../../core/request-handler.ts'; +import type { HttpResponse } from '../../core/types/internal/http.ts'; import type { OAuth2TokenResponse } from '../../core/types/internal/oauth.ts'; import { XmApiError } from '../../core/errors.ts'; @@ -20,7 +21,7 @@ export class OAuthEndpoint { */ async obtainTokens( options: { clientId?: string; clientSecret?: string } = {}, - ) { + ): Promise> { const { clientId, clientSecret } = options; const authState = this.http.getCurrentAuthState(); switch (authState.type) { diff --git a/src/endpoints/people/index.test.ts b/src/endpoints/people/index.test.ts index 82be443..813e35e 100644 --- a/src/endpoints/people/index.test.ts +++ b/src/endpoints/people/index.test.ts @@ -299,6 +299,7 @@ Deno.test('PersonsEndpoint', async (t) => { self: '/api/xm/1/sites/site-id', }, }, + roles: ['STANDARD_USER'], }; mockHttpClient.setReqRes([{ expectedRequest: { @@ -357,6 +358,7 @@ Deno.test('PersonsEndpoint', async (t) => { targetName: mockSinglePersonBody.targetName, firstName: mockSinglePersonBody.firstName, lastName: mockSinglePersonBody.lastName, + roles: ['STANDARD_USER'], }; mockHttpClient.setReqRes([{ expectedRequest: { @@ -380,6 +382,7 @@ Deno.test('PersonsEndpoint', async (t) => { firstName: mockSinglePersonBody.firstName, lastName: mockSinglePersonBody.lastName, recipientType: mockSinglePersonBody.recipientType, + roles: ['STANDARD_USER'], }; mockHttpClient.setReqRes([{ expectedRequest: { @@ -409,6 +412,7 @@ Deno.test('PersonsEndpoint', async (t) => { targetName: mockSinglePersonBody.targetName, firstName: mockSinglePersonBody.firstName, lastName: mockSinglePersonBody.lastName, + roles: ['STANDARD_USER'], properties: { department: 'Engineering', location: 'New York', diff --git a/src/endpoints/people/index.ts b/src/endpoints/people/index.ts index 9d9f4e1..7b5a3a5 100644 --- a/src/endpoints/people/index.ts +++ b/src/endpoints/people/index.ts @@ -1,12 +1,23 @@ import { ResourceClient } from '../../core/resource-client.ts'; import type { RequestHandler } from '../../core/request-handler.ts'; -import type { PaginatedResponse } from '../../core/types/endpoint/response.ts'; +import type { HttpResponse } from '../../core/types/internal/http.ts'; +import type { + EmptyHttpResponse, + PaginatedHttpResponse, + PaginatedResponse, +} from '../../core/types/endpoint/response.ts'; import type { DeleteOptions, GetOptions, RequestWithBodyOptions, } from '../../core/types/internal/http-methods.ts'; -import type { GetPersonParams, GetPersonsParams, Person } from './types.ts'; +import type { + CreatePerson, + GetPersonParams, + GetPersonsParams, + Person, + UpdatePerson, +} from './types.ts'; /** * Provides access to the people endpoints of the xMatters API. @@ -29,7 +40,7 @@ export class PersonsEndpoint { */ get( options?: Omit & { path?: string; query?: GetPersonsParams }, - ) { + ): Promise> { return this.http.get>(options); } @@ -44,7 +55,7 @@ export class PersonsEndpoint { getByIdentifier( identifier: string, options?: Omit & { query?: GetPersonParams }, - ) { + ): Promise> { return this.http.get({ ...options, path: identifier }); } @@ -57,9 +68,9 @@ export class PersonsEndpoint { * @throws {XmApiError} If the request fails */ save( - person: Partial, + person: CreatePerson | UpdatePerson, overrides?: Omit, - ) { + ): Promise> { return this.http.post({ ...overrides, body: person }); } @@ -74,7 +85,7 @@ export class PersonsEndpoint { delete( id: string, overrides?: Omit, - ) { + ): Promise { return this.http.delete({ ...overrides, path: id }); } } diff --git a/src/endpoints/people/types.ts b/src/endpoints/people/types.ts index 19c2afa..53c80e8 100644 --- a/src/endpoints/people/types.ts +++ b/src/endpoints/people/types.ts @@ -72,6 +72,21 @@ export interface Person { }; } +export type PersonRole = + | 'Standard User' + // deno-lint-ignore ban-types + | (string & {}); +export type PersonRoles = PersonRole | PersonRole[]; + +export type CreatePerson = + & Required<{ roles: PersonRoles } & Pick> + & Partial>; + +export type UpdatePerson = + & Required> + & Partial + & { roles?: PersonRoles }; + /** * Individual search field options that can be combined */ From f550dad21c530c63ccf988d70609f9ad56132a09 Mon Sep 17 00:00:00 2001 From: johan Date: Tue, 1 Jul 2025 18:40:13 -0700 Subject: [PATCH 084/101] Better specify types for Create vs Update scenarios of /groups --- src/endpoints/groups/index.ts | 11 +++++++++-- src/endpoints/groups/types.ts | 10 ++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/endpoints/groups/index.ts b/src/endpoints/groups/index.ts index b7f15a7..6e8011a 100644 --- a/src/endpoints/groups/index.ts +++ b/src/endpoints/groups/index.ts @@ -10,7 +10,14 @@ import type { GetOptions, RequestWithBodyOptions, } from '../../core/types/internal/http-methods.ts'; -import type { GetGroupParams, GetGroupsParams, Group, GroupQuotas } from './types.ts'; +import type { + CreateGroup, + GetGroupParams, + GetGroupsParams, + Group, + GroupQuotas, + UpdateGroup, +} from './types.ts'; import type { Person } from '../people/types.ts'; /** @@ -103,7 +110,7 @@ export class GroupsEndpoint { * @throws {XmApiError} If the request fails */ save( - group: Partial, + group: CreateGroup | UpdateGroup, overrides?: Omit, ): Promise> { return this.http.post({ ...overrides, body: group }); diff --git a/src/endpoints/groups/types.ts b/src/endpoints/groups/types.ts index 0828d3a..d2247f6 100644 --- a/src/endpoints/groups/types.ts +++ b/src/endpoints/groups/types.ts @@ -67,6 +67,16 @@ export interface Group { lastModified?: string; } +/** + * Type for creating a group. Must NOT include `id`. + */ +export type CreateGroup = Required> & Partial>; + +/** + * Type for updating a group. MUST include `id`. + */ +export type UpdateGroup = Required> & Partial; + /** * Individual search field options that can be combined */ From 01394a94131997f3d131e38bfcf72005a080b3e9 Mon Sep 17 00:00:00 2001 From: johan Date: Thu, 3 Jul 2025 23:22:22 -0700 Subject: [PATCH 085/101] Various small improvements and renamings for quality of life --- ...{axios-http-client.ts => axios-adapter.ts} | 0 src/core/defaults/index.ts | 2 +- src/core/errors.ts | 11 +---- src/core/request-builder.ts | 4 +- src/core/request-handler.ts | 24 +++++------ src/core/resource-client.test.ts | 2 - src/core/test-utils.ts | 43 ++++++++++++++----- .../endpoint/{params.ts => query-params.ts} | 0 src/core/types/internal/config.ts | 8 ++-- src/core/types/internal/http-methods.ts | 2 +- src/core/types/internal/http.ts | 2 +- .../{auth-state.ts => mutable-auth-state.ts} | 21 +++++++-- src/core/utils/config-validation.ts | 7 +-- src/endpoints/groups/types.ts | 2 +- src/endpoints/oauth/index.ts | 7 +-- src/endpoints/people/types.ts | 2 +- 16 files changed, 84 insertions(+), 53 deletions(-) rename src/core/defaults/{axios-http-client.ts => axios-adapter.ts} (100%) rename src/core/types/endpoint/{params.ts => query-params.ts} (100%) rename src/core/types/internal/{auth-state.ts => mutable-auth-state.ts} (56%) diff --git a/src/core/defaults/axios-http-client.ts b/src/core/defaults/axios-adapter.ts similarity index 100% rename from src/core/defaults/axios-http-client.ts rename to src/core/defaults/axios-adapter.ts diff --git a/src/core/defaults/index.ts b/src/core/defaults/index.ts index 4777d50..c379f9b 100644 --- a/src/core/defaults/index.ts +++ b/src/core/defaults/index.ts @@ -1,3 +1,3 @@ export { DefaultHttpClient } from './http-client.ts'; -export { axiosAdapter } from './axios-http-client.ts'; +export { axiosAdapter } from './axios-adapter.ts'; export { defaultLogger } from './logger.ts'; diff --git a/src/core/errors.ts b/src/core/errors.ts index ff81478..4cdea21 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -1,4 +1,4 @@ -import type { Headers } from './types/internal/http.ts'; +import type { HttpResponse } from './types/internal/http.ts'; /** * Base class for all errors thrown by the xMatters API client. @@ -22,14 +22,7 @@ export class XmApiError extends Error { */ constructor( message: string, - public readonly response?: { - /** The response body in its original format */ - body: unknown; - /** The HTTP status code that triggered this error */ - status: number; - /** Response headers that may contain additional error context */ - headers: Headers; - } | null, + public readonly response?: HttpResponse | null, public override readonly cause?: unknown, ) { // Use custom message if provided and meaningful, otherwise extract from response diff --git a/src/core/request-builder.ts b/src/core/request-builder.ts index 0c3933c..126c490 100644 --- a/src/core/request-builder.ts +++ b/src/core/request-builder.ts @@ -1,5 +1,5 @@ import type { Headers, HttpRequest } from './types/internal/http.ts'; -import type { QueryParams } from './types/endpoint/params.ts'; +import type { QueryParams } from './types/endpoint/query-params.ts'; import { XmApiError } from './errors.ts'; /** @@ -84,7 +84,7 @@ export class RequestBuilder { // Start with the base API URL, then manually append the path to preserve encoding // The xMatters API isn't always consistent in its accepting of // both encoded and non-encoded identifiers in paths. - // e.g. xm.groups.getByIdentifier('dd comics') === xm.groups.getByIdentifier('dd%20comics') + // e.g. xm.groups.getByIdentifier('dc comics') === xm.groups.getByIdentifier('dc%20comics') // but xm.people.getByIdentifier('lol@test.com') !== xm.people.getByIdentifier('lol%40test.com') // The latter will return a 404 Not Found error. // So we always use the path as-is, without encoding it. diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index d932563..8594e1d 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -7,7 +7,7 @@ import { type TokenRefreshCallback, type XmApiConfig, } from './types/internal/config.ts'; -import type { MutableAuthState } from './types/internal/auth-state.ts'; +import { AuthType, type MutableAuthState } from './types/internal/mutable-auth-state.ts'; import type { DeleteOptions, GetOptions, @@ -44,20 +44,20 @@ export class RequestHandler { // Initialize mutable auth state based on config type if (isBasicAuthConfig(initialConfig)) { this.mutableAuthState = { - type: 'basic', + type: AuthType.BASIC, username: initialConfig.username, password: initialConfig.password, }; } else if (isOAuthConfig(initialConfig)) { this.mutableAuthState = { - type: 'oauth', + type: AuthType.OAUTH, accessToken: initialConfig.accessToken, refreshToken: initialConfig.refreshToken, clientId: initialConfig.clientId, }; } else if (isAuthCodeConfig(initialConfig)) { this.mutableAuthState = { - type: 'authCode', + type: AuthType.AUTH_CODE, authorizationCode: initialConfig.authorizationCode, clientId: initialConfig.clientId, clientSecret: initialConfig.clientSecret, @@ -83,7 +83,7 @@ export class RequestHandler { request: RequestBuildOptions, ): Promise> { // Check if token refresh is needed before making the request - if (this.mutableAuthState.type === 'oauth' && this.isTokenExpired()) { + if (this.mutableAuthState.type === AuthType.OAUTH && this.isTokenExpired()) { await this.refreshAccessToken(); } const fullRequest = this.requestBuilder.build(request); @@ -104,7 +104,7 @@ export class RequestHandler { // Handle OAuth token expiry/refresh first if ( response.status === 401 && - this.mutableAuthState.type === 'oauth' && + this.mutableAuthState.type === AuthType.OAUTH && currentAttempt === 0 ) { await this.refreshAccessToken(); @@ -123,7 +123,7 @@ export class RequestHandler { const delay = this.exponentialBackoff(currentAttempt); // Respect Retry-After header for rate limits if present let finalDelay = delay; - if (response.status === 429 && response.headers['retry-after']) { + if (response.status === 429 && response.headers?.['retry-after']) { const retryAfter = parseInt(response.headers['retry-after'], 10); if (!isNaN(retryAfter)) { finalDelay = retryAfter * 1000; @@ -190,10 +190,10 @@ export class RequestHandler { * Creates the authorization header value based on the authentication type */ private createAuthHeader(): string | undefined { - if (this.mutableAuthState.type === 'oauth') { + if (this.mutableAuthState.type === AuthType.OAUTH) { return `Bearer ${this.mutableAuthState.accessToken}`; } - if (this.mutableAuthState.type === 'basic') { + if (this.mutableAuthState.type === AuthType.BASIC) { // In Deno, we use TextEncoder for proper UTF-8 encoding const encoder = new TextEncoder(); const authString = `${this.mutableAuthState.username}:${this.mutableAuthState.password}`; @@ -204,7 +204,7 @@ export class RequestHandler { private async refreshAccessToken(): Promise { try { - if (this.mutableAuthState.type !== 'oauth') { + if (this.mutableAuthState.type !== AuthType.OAUTH) { throw new XmApiError('No OAuth configuration available for token refresh'); } const params = new URLSearchParams({ @@ -249,7 +249,7 @@ export class RequestHandler { */ async handleNewOAuthTokens(tokenResponse: OAuth2TokenResponse, clientId: string): Promise { this.mutableAuthState = { - type: 'oauth', + type: AuthType.OAUTH, accessToken: tokenResponse.access_token, refreshToken: tokenResponse.refresh_token, clientId: clientId, @@ -279,7 +279,7 @@ export class RequestHandler { } private isTokenExpired(): boolean { - if (this.mutableAuthState.type !== 'oauth') return false; + if (this.mutableAuthState.type !== AuthType.OAUTH) return false; // If we don't have expiration info, assume it's valid // since consumers likely cache tokens and we don't want to // prematurely refresh tokens that are probably still good. diff --git a/src/core/resource-client.test.ts b/src/core/resource-client.test.ts index 162b90a..c852717 100644 --- a/src/core/resource-client.test.ts +++ b/src/core/resource-client.test.ts @@ -241,8 +241,6 @@ Deno.test('ResourceClient', async (t) => { }, mockedResponse: { status: 204, - headers: {}, - body: '', }, }]); await client.delete({ path: '123' }); diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts index ec1f60b..e890242 100644 --- a/src/core/test-utils.ts +++ b/src/core/test-utils.ts @@ -18,16 +18,33 @@ import { FakeTime } from 'std/testing/time.ts'; import { expect } from 'std/expect/mod.ts'; /** - * Request-response pair for testing - * Set up expected requests and their mocked responses or errors. + * Request-response pair for testing - HTTP response case + * Set up expected requests and their mocked HTTP responses (any status code). + */ +interface MockRequestWithResponse { + expectedRequest: Partial; + mockedResponse: Partial; +} + +/** + * Request-response pair for testing - error case + * Set up expected requests and their mocked errors. + * This is used to simulate network errors or API failures. + * The error will be thrown when the request is made. + * (!) Not to be confused with HTTP error responses + * (like 404, 500, etc.) which are still HTTP responses. */ -interface MockRequestResponse { +interface MockRequestError { expectedRequest: Partial; - mockedResponse?: Partial; - /** If provided, the request will throw this error instead of returning a response */ - mockedError?: Error; + mockedError: Error; } +/** + * Request-response pair for testing + * Set up expected requests and their mocked responses or errors. + */ +type MockRequestResponse = MockRequestWithResponse | MockRequestError; + /** * Mock HTTP client that prevents network calls during tests. * Responses are consumed in FIFO order and validated against expected requests. @@ -47,20 +64,26 @@ export class MockHttpClient implements HttpClient { } const currentPair = this.requestResponsePairs[this.requests.length - 1]; this.validateRequest(request, currentPair.expectedRequest); - if (currentPair.mockedError) { + + // Handle error case + if ('mockedError' in currentPair) { return Promise.reject(currentPair.mockedError); } - if (!currentPair.mockedResponse) { + + // Validate response case has required response + if (!('mockedResponse' in currentPair) || !currentPair.mockedResponse) { return Promise.reject( new Error( - `MockHttpClient: Request #${this.requests.length} must have either mockedResponse or mockedError`, + `MockHttpClient: Request #${this.requests.length} must have either mockedError or mockedResponse`, ), ); } + + // Handle response case const response: HttpResponse = { status: currentPair.mockedResponse.status || 200, body: currentPair.mockedResponse.body, - headers: currentPair.mockedResponse.headers || {}, + headers: currentPair.mockedResponse.headers, }; return Promise.resolve(response); } diff --git a/src/core/types/endpoint/params.ts b/src/core/types/endpoint/query-params.ts similarity index 100% rename from src/core/types/endpoint/params.ts rename to src/core/types/endpoint/query-params.ts diff --git a/src/core/types/internal/config.ts b/src/core/types/internal/config.ts index 176356f..6b2efe0 100644 --- a/src/core/types/internal/config.ts +++ b/src/core/types/internal/config.ts @@ -29,7 +29,7 @@ export type TokenRefreshCallback = ( /** * Base configuration options shared by all authentication methods. */ -export interface XmApiBaseConfig { +interface XmApiBaseConfig { hostname: string; httpClient?: HttpClient; logger?: Logger; @@ -42,7 +42,7 @@ export interface XmApiBaseConfig { * Basic auth configuration (can transition to OAuth). * No clientId field - this is pure basic auth. */ -export interface BasicAuthConfig extends XmApiBaseConfig { +interface BasicAuthConfig extends XmApiBaseConfig { username: string; password: string; } @@ -51,7 +51,7 @@ export interface BasicAuthConfig extends XmApiBaseConfig { * Auth code configuration (must call obtainTokens before API calls). * ClientId is required - no discovery path. */ -export interface AuthCodeConfig extends XmApiBaseConfig { +interface AuthCodeConfig extends XmApiBaseConfig { authorizationCode: string; // Changed from authCode to match xMatters API clientId: string; clientSecret?: string; // Optional client secret for enhanced security @@ -61,7 +61,7 @@ export interface AuthCodeConfig extends XmApiBaseConfig { * OAuth configuration (ready for API calls). * All OAuth fields are required. */ -export interface OAuthConfig extends XmApiBaseConfig { +interface OAuthConfig extends XmApiBaseConfig { accessToken: string; refreshToken: string; clientId: string; diff --git a/src/core/types/internal/http-methods.ts b/src/core/types/internal/http-methods.ts index 1abd861..c0889f2 100644 --- a/src/core/types/internal/http-methods.ts +++ b/src/core/types/internal/http-methods.ts @@ -8,7 +8,7 @@ import type { Headers } from './http.ts'; /** * Base interface for all HTTP method options */ -export interface HttpMethodBaseOptions { +interface HttpMethodBaseOptions { /** The path portion of the URL, relative to the API version path */ path: string; /** Optional headers to send with the request */ diff --git a/src/core/types/internal/http.ts b/src/core/types/internal/http.ts index 16b001f..57f1211 100644 --- a/src/core/types/internal/http.ts +++ b/src/core/types/internal/http.ts @@ -18,7 +18,7 @@ export interface HttpResponse { /** The HTTP status code */ status: number; /** Response headers */ - headers: Headers; + headers?: Headers; } /** diff --git a/src/core/types/internal/auth-state.ts b/src/core/types/internal/mutable-auth-state.ts similarity index 56% rename from src/core/types/internal/auth-state.ts rename to src/core/types/internal/mutable-auth-state.ts index 26d76c7..af0df33 100644 --- a/src/core/types/internal/auth-state.ts +++ b/src/core/types/internal/mutable-auth-state.ts @@ -3,15 +3,30 @@ * These types define the mutable authentication state that changes during RequestHandler lifetime. */ +/** + * Authentication type constants used throughout the library. + * Centralizes string literals to prevent typos and ensure consistency. + */ +export const AuthType = { + BASIC: 'basic', + AUTH_CODE: 'authCode', + OAUTH: 'oauth', +} as const; + /** * Mutable authentication state - the only thing that changes during RequestHandler lifetime. * Uses a discriminated union to ensure type-safe access to authentication properties. */ export type MutableAuthState = - | { type: 'basic'; username: string; password: string } - | { type: 'authCode'; authorizationCode: string; clientId: string; clientSecret?: string } + | { type: typeof AuthType.BASIC; username: string; password: string } + | { + type: typeof AuthType.AUTH_CODE; + authorizationCode: string; + clientId: string; + clientSecret?: string; + } | { - type: 'oauth'; + type: typeof AuthType.OAUTH; accessToken: string; refreshToken: string; clientId: string; diff --git a/src/core/utils/config-validation.ts b/src/core/utils/config-validation.ts index 5764cd9..9db3db7 100644 --- a/src/core/utils/config-validation.ts +++ b/src/core/utils/config-validation.ts @@ -1,5 +1,6 @@ import { XmApiError } from '../errors.ts'; import type { XmApiConfig } from '../types/internal/config.ts'; +import { isAuthCodeConfig, isBasicAuthConfig, isOAuthConfig } from '../types/internal/config.ts'; /** * Validates that a hostname is a valid xMatters hostname. @@ -42,9 +43,9 @@ export function validateConfig(config: XmApiConfig): void { } } // 4. Determine which auth methods are present - const hasBasicAuth = 'username' in config && 'password' in config; - const hasAuthCode = 'authorizationCode' in config; - const hasOAuthTokens = 'accessToken' in config && 'refreshToken' in config; + const hasBasicAuth = isBasicAuthConfig(config); + const hasAuthCode = isAuthCodeConfig(config); + const hasOAuthTokens = isOAuthConfig(config); const configCount = [hasBasicAuth, hasAuthCode, hasOAuthTokens].filter(Boolean).length; // 5. Validate exactly one auth method is provided if (configCount === 0) { diff --git a/src/endpoints/groups/types.ts b/src/endpoints/groups/types.ts index d2247f6..7a3e018 100644 --- a/src/endpoints/groups/types.ts +++ b/src/endpoints/groups/types.ts @@ -4,7 +4,7 @@ import type { SearchParams, SortOrder, StatusParams, -} from '../../core/types/endpoint/params.ts'; +} from '../../core/types/endpoint/query-params.ts'; /** * Represents a group in xMatters. diff --git a/src/endpoints/oauth/index.ts b/src/endpoints/oauth/index.ts index 58941c4..063b6de 100644 --- a/src/endpoints/oauth/index.ts +++ b/src/endpoints/oauth/index.ts @@ -2,6 +2,7 @@ import type { RequestHandler } from '../../core/request-handler.ts'; import type { HttpResponse } from '../../core/types/internal/http.ts'; import type { OAuth2TokenResponse } from '../../core/types/internal/oauth.ts'; import { XmApiError } from '../../core/errors.ts'; +import { AuthType } from '../../core/types/internal/mutable-auth-state.ts'; export class OAuthEndpoint { constructor( @@ -25,7 +26,7 @@ export class OAuthEndpoint { const { clientId, clientSecret } = options; const authState = this.http.getCurrentAuthState(); switch (authState.type) { - case 'basic': { + case AuthType.BASIC: { return await this.getOAuthTokenByPassword({ username: authState.username, password: authState.password, @@ -33,7 +34,7 @@ export class OAuthEndpoint { clientSecret, }); } - case 'authCode': { + case AuthType.AUTH_CODE: { const resolvedClientSecret = clientSecret || authState.clientSecret; return await this.getOAuthTokenByAuthorizationCode({ authorizationCode: authState.authorizationCode, @@ -41,7 +42,7 @@ export class OAuthEndpoint { clientSecret: resolvedClientSecret, }); } - case 'oauth': { + case AuthType.OAUTH: { throw new XmApiError('Already have OAuth tokens - no need to call obtainTokens()'); } default: { diff --git a/src/endpoints/people/types.ts b/src/endpoints/people/types.ts index 53c80e8..6f91082 100644 --- a/src/endpoints/people/types.ts +++ b/src/endpoints/people/types.ts @@ -4,7 +4,7 @@ import type { SearchParams, SortOrder, StatusParams, -} from '../../core/types/endpoint/params.ts'; +} from '../../core/types/endpoint/query-params.ts'; /** * Represents a person in xMatters. From b4905b10b0e2505314a09aad9cb7a20ea28b9457 Mon Sep 17 00:00:00 2001 From: johan Date: Fri, 4 Jul 2025 18:51:21 -0700 Subject: [PATCH 086/101] revisit error handling in request handler --- src/core/request-handler.ts | 110 +++++++++++++++++++----------------- 1 file changed, 57 insertions(+), 53 deletions(-) diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index 8594e1d..5e1762b 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -97,58 +97,51 @@ export class RequestHandler { }; } } - try { - const response = await this.sendWithLogging(fullRequest); - if (response.status >= 400) { - const currentAttempt = fullRequest.retryAttempt ?? 0; - // Handle OAuth token expiry/refresh first - if ( - response.status === 401 && - this.mutableAuthState.type === AuthType.OAUTH && - currentAttempt === 0 - ) { - await this.refreshAccessToken(); - // Retry the original request with new token - return this.send({ - ...request, - retryAttempt: 1, - }); - } - // For rate limits (429) or server errors (5xx), retry with exponential backoff - if ( - (response.status === 429 || response.status >= 500) && - currentAttempt < this.maxRetries - ) { - // Calculate delay based on retry attempt - const delay = this.exponentialBackoff(currentAttempt); - // Respect Retry-After header for rate limits if present - let finalDelay = delay; - if (response.status === 429 && response.headers?.['retry-after']) { - const retryAfter = parseInt(response.headers['retry-after'], 10); - if (!isNaN(retryAfter)) { - finalDelay = retryAfter * 1000; - } + const response = await this.sendWithLogging(fullRequest); + if (response.status >= 400) { + const currentAttempt = fullRequest.retryAttempt ?? 0; + // Handle OAuth token expiry/refresh first + if ( + response.status === 401 && + this.mutableAuthState.type === AuthType.OAUTH && + currentAttempt === 0 + ) { + await this.refreshAccessToken(); + // Retry the original request with new token + return this.send({ + ...request, + retryAttempt: 1, + }); + } + // For rate limits (429) or server errors (5xx), retry with exponential backoff + if ( + (response.status === 429 || response.status >= 500) && + currentAttempt < this.maxRetries + ) { + // Calculate delay based on retry attempt + const delay = this.exponentialBackoff(currentAttempt); + // Respect Retry-After header for rate limits if present + let finalDelay = delay; + if (response.status === 429 && response.headers?.['retry-after']) { + const retryAfter = parseInt(response.headers['retry-after'], 10); + if (!isNaN(retryAfter)) { + finalDelay = retryAfter * 1000; } - this.logger.debug( - `Request failed with status ${response.status}, retrying in ${finalDelay}ms (attempt ${ - currentAttempt + 1 - }/${this.maxRetries})`, - ); - await new Promise((resolve) => setTimeout(resolve, finalDelay)); - return this.send({ - ...request, - retryAttempt: currentAttempt + 1, - }); } - throw new XmApiError('', response); - } - return response as HttpResponse; - } catch (error) { - if (error instanceof XmApiError) { - throw error; + this.logger.debug( + `Request failed with status ${response.status}, retrying in ${finalDelay}ms (attempt ${ + currentAttempt + 1 + }/${this.maxRetries})`, + ); + await new Promise((resolve) => setTimeout(resolve, finalDelay)); + return this.send({ + ...request, + retryAttempt: currentAttempt + 1, + }); } - throw new XmApiError('Request failed', null, error); + throw new XmApiError('', response); } + return response as HttpResponse; } get(options: GetOptions): Promise> { @@ -173,17 +166,28 @@ export class RequestHandler { /** * Sends an HTTP request with logging. - * This wrapper ensures consistent logging across all HTTP calls. + * This wrapper ensures consistent logging across all HTTP calls and + * guarantees that only XmApiError instances are thrown. */ private async sendWithLogging( request: HttpRequest, ): Promise { const startTime = Date.now(); this.logger.debug(`--> ${request.method} ${request.url}`); - const response = await this.client.send(request); - const duration = Date.now() - startTime; - this.logger.debug(`<-- ${response.status} (${duration}ms)`); - return response; + try { + const response = await this.client.send(request); + const duration = Date.now() - startTime; + this.logger.debug(`<-- ${response.status} (${duration}ms)`); + return response; + } catch (error) { + const duration = Date.now() - startTime; + this.logger.debug(`<-- ERROR (${duration}ms)`); + // Only wrap if not already an XmApiError to avoid double-wrapping + if (error instanceof XmApiError) { + throw error; + } + throw new XmApiError('Request failed', null, error); + } } /** From 79c66d14404a457a420b866b3dd93eac7708b68d Mon Sep 17 00:00:00 2001 From: johan Date: Fri, 4 Jul 2025 23:41:27 -0700 Subject: [PATCH 087/101] Figured out a pattern I like for request building options types Add integrations endpoint and trigger method --- .vscode/typescript.code-snippets | 22 ++++------ src/core/request-builder.test.ts | 25 ++++++----- src/core/request-builder.ts | 38 ++-------------- src/core/request-handler.ts | 25 +++++------ src/core/resource-client.ts | 16 +++---- src/core/types/internal/http-methods.ts | 39 ---------------- .../internal/request-building-options.ts | 44 +++++++++++++++++++ src/endpoints/groups/index.ts | 28 +++++------- src/endpoints/integrations/index.ts | 40 +++++++++++++++++ src/endpoints/people/index.ts | 22 ++++------ src/index.ts | 7 ++- 11 files changed, 153 insertions(+), 153 deletions(-) delete mode 100644 src/core/types/internal/http-methods.ts create mode 100644 src/core/types/internal/request-building-options.ts create mode 100644 src/endpoints/integrations/index.ts diff --git a/.vscode/typescript.code-snippets b/.vscode/typescript.code-snippets index de73099..0c2f866 100644 --- a/.vscode/typescript.code-snippets +++ b/.vscode/typescript.code-snippets @@ -6,11 +6,7 @@ "import { ResourceClient } from '../../core/resource-client.ts';", "import type { RequestHandler } from '../../core/request-handler.ts';", "import type { PaginatedResponse } from '../../core/types/endpoint/response.ts';", - "import type {", - " DeleteOptions,", - " GetOptions,", - " RequestWithBodyOptions,", - "} from '../../core/types/internal/http-methods.ts';", + "import type { Options } from '../../core/types/internal/request-building-options.ts';", "import type { Get${1:Resource}Params, Get${1:Resource}sParams, ${1:Resource} } from './types.ts';", "", "/**", @@ -32,7 +28,7 @@ " * @returns The HTTP response containing a paginated list of ${2:${1/(.*)/${1:/downcase}/}}s", " * @throws {XmApiError} If the request fails", " */", - " get(options?: Omit & { path?: string; query?: Get${1:Resource}sParams }) {", + " get(options?: Options & { query?: Get${1:Resource}sParams }) {", " return this.http.get>(options);", " }", "", @@ -44,7 +40,7 @@ " * @returns The HTTP response containing the ${2:${1/(.*)/${1:/downcase}/}}", " * @throws {XmApiError} If the request fails", " */", - " getByIdentifier(identifier: string, options?: Omit & { query?: Get${1:Resource}Params }) {", + " getByIdentifier(identifier: string, options?: Options & { query?: Get${1:Resource}Params }) {", " return this.http.get<${1:Resource}>({ ...options, path: identifier });", " }", "", @@ -52,24 +48,24 @@ " * Create a new ${2:${1/(.*)/${1:/downcase}/}} or update an existing one", " *", " * @param ${2:${1/(.*)/${1:/downcase}/}} The ${2:${1/(.*)/${1:/downcase}/}} to create or update", - " * @param overrides Optional request overrides like custom headers", + " * @param options Optional request options such as custom headers", " * @returns The HTTP response containing the created or updated ${2:${1/(.*)/${1:/downcase}/}}", " * @throws {XmApiError} If the request fails", " */", - " save(${2:${1/(.*)/${1:/downcase}/}}: Partial<${1:Resource}>, overrides?: Omit) {", - " return this.http.post<${1:Resource}>({ ...overrides, body: ${2:${1/(.*)/${1:/downcase}/}} });", + " save(${2:${1/(.*)/${1:/downcase}/}}: Partial<${1:Resource}>, options?: Options) {", + " return this.http.post<${1:Resource}>({ ...options, body: ${2:${1/(.*)/${1:/downcase}/}} });", " }", "", " /**", " * Delete a ${2:${1/(.*)/${1:/downcase}/}} by ID", " *", " * @param id The ID of the ${2:${1/(.*)/${1:/downcase}/}} to delete", - " * @param overrides Optional request overrides like custom headers", + " * @param options Optional request options such as custom headers", " * @returns The HTTP response", " * @throws {XmApiError} If the request fails", " */", - " delete(id: string, overrides?: Omit) {", - " return this.http.delete({ ...overrides, path: id });", + " delete(id: string, options?: Options) {", + " return this.http.delete({ ...options, path: id });", " }", "}", "$0" diff --git a/src/core/request-builder.test.ts b/src/core/request-builder.test.ts index 3e0950d..76c9dd6 100644 --- a/src/core/request-builder.test.ts +++ b/src/core/request-builder.test.ts @@ -1,5 +1,6 @@ import { expect } from 'std/expect/mod.ts'; -import { RequestBuilder, type RequestBuildOptions } from './request-builder.ts'; +import { RequestBuilder } from './request-builder.ts'; +import type { RequestBuildingOptions } from './types/internal/request-building-options.ts'; import type { Headers } from './types/internal/http.ts'; import { XmApiError } from './errors.ts'; @@ -56,7 +57,7 @@ Deno.test('RequestBuilder', async (t) => { }); await t.step('preserves existing query parameters in external URLs', () => { - const options: RequestBuildOptions = { + const options: RequestBuildingOptions = { fullUrl: 'https://api.external-service.com/search?existing=param&another=value', query: { additional: 'param', new: 'value' }, }; @@ -73,7 +74,7 @@ Deno.test('RequestBuilder', async (t) => { const { builder: customBuilder } = createRequestBuilderTestSetup({ hostname: 'https://custom.xmatters.com', }); - const options: RequestBuildOptions = { + const options: RequestBuildingOptions = { path: '/notifications', }; const request = customBuilder.build(options); @@ -105,7 +106,7 @@ Deno.test('RequestBuilder', async (t) => { const { builder: emptyHeadersBuilder } = createRequestBuilderTestSetup({ defaultHeaders: {}, }); - const options: RequestBuildOptions = { + const options: RequestBuildingOptions = { path: '/sites', headers: { 'Custom-Header': 'value' }, }; @@ -116,7 +117,7 @@ Deno.test('RequestBuilder', async (t) => { await t.step('Query Parameter Handling', async (t) => { await t.step('handles empty query object', () => { - const options: RequestBuildOptions = { + const options: RequestBuildingOptions = { path: '/devices', query: {}, }; @@ -125,7 +126,7 @@ Deno.test('RequestBuilder', async (t) => { }); await t.step('filters out null and undefined query parameters', () => { - const options: RequestBuildOptions = { + const options: RequestBuildingOptions = { path: '/events', query: { status: 'active', @@ -143,7 +144,7 @@ Deno.test('RequestBuilder', async (t) => { }); await t.step('handles array query parameters by joining with commas', () => { - const options: RequestBuildOptions = { + const options: RequestBuildingOptions = { path: '/groups/123', query: { embed: ['supervisors', 'services', 'observers'], @@ -159,7 +160,7 @@ Deno.test('RequestBuilder', async (t) => { }); await t.step('handles empty arrays gracefully', () => { - const options: RequestBuildOptions = { + const options: RequestBuildingOptions = { path: '/groups', query: { embed: [], @@ -173,7 +174,7 @@ Deno.test('RequestBuilder', async (t) => { }); await t.step('handles mixed array types by converting to strings', () => { - const options: RequestBuildOptions = { + const options: RequestBuildingOptions = { path: '/items', query: { ids: [1, 2, 3], @@ -191,7 +192,7 @@ Deno.test('RequestBuilder', async (t) => { await t.step('Default Behavior', async (t) => { await t.step('defaults method to GET when not specified', () => { - const options: RequestBuildOptions = { + const options: RequestBuildingOptions = { path: '/users', }; const request = builder.build(options); @@ -200,7 +201,7 @@ Deno.test('RequestBuilder', async (t) => { }); await t.step('preserves retry attempt when provided', () => { - const options: RequestBuildOptions = { + const options: RequestBuildingOptions = { path: '/shifts', retryAttempt: 2, }; @@ -260,7 +261,7 @@ Deno.test('RequestBuilder', async (t) => { await t.step('Integration Tests', async (t) => { await t.step('builds complex request with all options', () => { - const complexOptions: RequestBuildOptions = { + const complexOptions: RequestBuildingOptions = { path: '/forms/abc123/submissions', method: 'PATCH', query: { diff --git a/src/core/request-builder.ts b/src/core/request-builder.ts index 126c490..1549083 100644 --- a/src/core/request-builder.ts +++ b/src/core/request-builder.ts @@ -1,39 +1,7 @@ +import { XmApiError } from './errors.ts'; import type { Headers, HttpRequest } from './types/internal/http.ts'; import type { QueryParams } from './types/endpoint/query-params.ts'; -import { XmApiError } from './errors.ts'; - -/** - * Request options for building HTTP requests. - * Either path or fullUrl must be provided, but not both. - */ -export interface RequestBuildOptions { - /** - * The path relative to the API version path. - * Do not include the API version (/api/xm/1). - * Must start with a forward slash. - * @example "/people" - */ - path?: string; - /** - * A fully qualified URL. - * Use to bypass URL building logic entirely. - * @example "https://api.external-service.com/v2/endpoint" - * @example "https://you.xmatters.com/api/integration/1/functions/6358eaf3-6213-42fc-8629-e823cf5739cb/triggers?apiKey=a12bcde3-456f-7g89-123a-b456789cd000" - */ - fullUrl?: string; - /** The HTTP method to use for the request */ - method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; - /** Optional headers to send with the request */ - headers?: Headers; - /** Optional query parameters to include in the URL */ - query?: QueryParams; - /** Optional request body */ - body?: unknown; - /** Used internally for retry logic */ - retryAttempt?: number; - /** Whether to skip adding authentication headers to this request */ - skipAuth?: boolean; -} +import type { RequestBuildingOptions } from './types/internal/request-building-options.ts'; export class RequestBuilder { private readonly apiVersionPath = '/api/xm/1'; @@ -63,7 +31,7 @@ export class RequestBuilder { return searchParams.toString(); } - build(options: RequestBuildOptions): HttpRequest { + build(options: RequestBuildingOptions): HttpRequest { let finalUrl: string; if (options.fullUrl && options.path) { throw new XmApiError( diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index 5e1762b..ab81ddd 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -8,14 +8,13 @@ import { type XmApiConfig, } from './types/internal/config.ts'; import { AuthType, type MutableAuthState } from './types/internal/mutable-auth-state.ts'; -import type { - DeleteOptions, - GetOptions, - RequestWithBodyOptions, -} from './types/internal/http-methods.ts'; import { XmApiError } from './errors.ts'; import type { OAuth2TokenResponse } from './types/internal/oauth.ts'; -import { RequestBuilder, type RequestBuildOptions } from './request-builder.ts'; +import { RequestBuilder } from './request-builder.ts'; +import type { + RequestBuildingOptions, + RequestOptions, +} from './types/internal/request-building-options.ts'; import { DefaultHttpClient, defaultLogger } from './defaults/index.ts'; import denoJson from '../../deno.json' with { type: 'json' }; @@ -80,7 +79,7 @@ export class RequestHandler { } async send( - request: RequestBuildOptions, + request: RequestBuildingOptions, ): Promise> { // Check if token refresh is needed before making the request if (this.mutableAuthState.type === AuthType.OAUTH && this.isTokenExpired()) { @@ -144,23 +143,23 @@ export class RequestHandler { return response as HttpResponse; } - get(options: GetOptions): Promise> { - return this.send(options); + get(options: RequestOptions): Promise> { + return this.send({ ...options, method: 'GET' }); } - post(options: RequestWithBodyOptions): Promise> { + post(options: RequestOptions): Promise> { return this.send({ ...options, method: 'POST' }); } - put(options: RequestWithBodyOptions): Promise> { + put(options: RequestOptions): Promise> { return this.send({ ...options, method: 'PUT' }); } - patch(options: RequestWithBodyOptions): Promise> { + patch(options: RequestOptions): Promise> { return this.send({ ...options, method: 'PATCH' }); } - delete(options: DeleteOptions): Promise> { + delete(options: RequestOptions): Promise> { return this.send({ ...options, method: 'DELETE' }); } diff --git a/src/core/resource-client.ts b/src/core/resource-client.ts index df00c66..3c92bc3 100644 --- a/src/core/resource-client.ts +++ b/src/core/resource-client.ts @@ -1,8 +1,4 @@ -import type { - DeleteOptions, - GetOptions, - RequestWithBodyOptions, -} from './types/internal/http-methods.ts'; +import type { ResourceOptions } from './types/internal/request-building-options.ts'; import type { RequestHandler } from './request-handler.ts'; import { XmApiError } from './errors.ts'; @@ -33,35 +29,35 @@ export class ResourceClient { return `${this.basePath}/${cleanPath}`; } - get(options?: Omit & { path?: string }) { + get(options?: ResourceOptions) { return this.http.get({ ...options, path: this.buildPath(options?.path), }); } - post(options: Omit & { path?: string }) { + post(options: ResourceOptions) { return this.http.post({ ...options, path: this.buildPath(options.path), }); } - put(options: Omit & { path?: string }) { + put(options: ResourceOptions) { return this.http.put({ ...options, path: this.buildPath(options.path), }); } - patch(options: Omit & { path?: string }) { + patch(options: ResourceOptions) { return this.http.patch({ ...options, path: this.buildPath(options.path), }); } - delete(options: Omit & { path?: string }) { + delete(options: ResourceOptions) { return this.http.delete({ ...options, path: this.buildPath(options.path), diff --git a/src/core/types/internal/http-methods.ts b/src/core/types/internal/http-methods.ts deleted file mode 100644 index c0889f2..0000000 --- a/src/core/types/internal/http-methods.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * HTTP method option types used internally by the request handling layer. - * These types define the shape of options passed to HTTP method calls. - */ - -import type { Headers } from './http.ts'; - -/** - * Base interface for all HTTP method options - */ -interface HttpMethodBaseOptions { - /** The path portion of the URL, relative to the API version path */ - path: string; - /** Optional headers to send with the request */ - headers?: Headers; - /** Whether to skip adding authentication headers */ - skipAuth?: boolean; -} - -/** - * Options for GET requests - */ -export interface GetOptions extends HttpMethodBaseOptions { - /** Optional query parameters */ - query?: Record; -} - -/** - * Options for POST, PUT, and PATCH requests - */ -export interface RequestWithBodyOptions extends HttpMethodBaseOptions { - /** The request body */ - body?: unknown; -} - -/** - * Options for DELETE requests - */ -export type DeleteOptions = HttpMethodBaseOptions; diff --git a/src/core/types/internal/request-building-options.ts b/src/core/types/internal/request-building-options.ts new file mode 100644 index 0000000..32f2254 --- /dev/null +++ b/src/core/types/internal/request-building-options.ts @@ -0,0 +1,44 @@ +import type { QueryParams } from '../endpoint/query-params.ts'; +import type { Headers } from './http.ts'; + +/** + * Request options for building HTTP requests. + * Either path or fullUrl must be provided, but not both. + */ +export interface RequestBuildingOptions { + /** + * The path relative to the API version path. + * Do not include the API version (/api/xm/1). + * Must start with a forward slash. + * @example "/people" + */ + path?: string; + /** + * A fully qualified URL. + * Use to bypass URL building logic entirely. + * @example "https://api.external-service.com/v2/endpoint" + * @example "https://you.xmatters.com/api/integration/1/functions/6358eaf3-6213-42fc-8629-e823cf5739cb/triggers?apiKey=a12bcde3-456f-7g89-123a-b456789cd000" + */ + fullUrl?: string; + /** The HTTP method to use for the request */ + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + /** Optional headers to send with the request */ + headers?: Headers; + /** Optional query parameters to include in the URL */ + query?: QueryParams; + /** Optional request body */ + body?: unknown; + /** Used internally for retry logic */ + retryAttempt?: number; + /** Whether to skip adding authentication headers to this request */ + skipAuth?: boolean; +} + +// Used internally for the request-handler convenience http methods (get, post, put, patch, delete) +export type RequestOptions = Omit; +// Used internally for resource client convenience methods (get, post, put, patch, delete) +export type ResourceOptions = Omit; +// Consumer-facing type for use in endpoint methods. +// Body is omitted because Options is meant to be used for consumer-facing methods +// and typically methods that require a body (like POST/PUT) will have a dedicated payload argument +export type Options = Omit; diff --git a/src/endpoints/groups/index.ts b/src/endpoints/groups/index.ts index 6e8011a..6d3211a 100644 --- a/src/endpoints/groups/index.ts +++ b/src/endpoints/groups/index.ts @@ -5,11 +5,7 @@ import type { PaginatedHttpResponse, PaginatedResponse, } from '../../core/types/endpoint/response.ts'; -import type { - DeleteOptions, - GetOptions, - RequestWithBodyOptions, -} from '../../core/types/internal/http-methods.ts'; +import type { Options } from '../../core/types/internal/request-building-options.ts'; import type { CreateGroup, GetGroupParams, @@ -40,7 +36,7 @@ export class GroupsEndpoint { * @throws {XmApiError} If the request fails */ get( - options?: Omit & { path?: string; query?: GetGroupsParams }, + options?: Options & { query?: GetGroupsParams }, ): Promise> { return this.http.get>(options); } @@ -55,7 +51,7 @@ export class GroupsEndpoint { */ getSupervisors( groupId: string, - options?: Omit, + options?: Options, ): Promise> { return this.http.get>({ ...options, path: `${groupId}/supervisors` }); } @@ -70,7 +66,7 @@ export class GroupsEndpoint { */ getRecipients( groupId: string, - options?: Omit, + options?: Options, ): Promise> { return this.http.get>({ ...options, path: `${groupId}/recipients` }); } @@ -82,7 +78,7 @@ export class GroupsEndpoint { * @returns The HTTP response containing the group license quotas * @throws {XmApiError} If the request fails */ - getLicenseQuotas(options?: Omit): Promise> { + getLicenseQuotas(options?: Options): Promise> { return this.http.get({ ...options, path: 'license-quotas' }); } @@ -96,7 +92,7 @@ export class GroupsEndpoint { */ getByIdentifier( identifier: string, - options?: Omit & { query?: GetGroupParams }, + options?: Options & { query?: GetGroupParams }, ): Promise> { return this.http.get({ ...options, path: identifier }); } @@ -105,26 +101,26 @@ export class GroupsEndpoint { * Create a new group or update an existing one * * @param group The group to create or update - * @param overrides Optional request overrides like custom headers + * @param options Optional request options such as custom headers * @returns The HTTP response containing the created or updated group * @throws {XmApiError} If the request fails */ save( group: CreateGroup | UpdateGroup, - overrides?: Omit, + options?: Options, ): Promise> { - return this.http.post({ ...overrides, body: group }); + return this.http.post({ ...options, body: group }); } /** * Delete a group by ID * * @param id The ID of the group to delete - * @param overrides Optional request overrides like custom headers + * @param options Optional request options such as custom headers * @returns The HTTP response * @throws {XmApiError} If the request fails */ - delete(id: string, overrides?: Omit): Promise> { - return this.http.delete({ ...overrides, path: id }); + delete(id: string, options?: Options): Promise> { + return this.http.delete({ ...options, path: id }); } } diff --git a/src/endpoints/integrations/index.ts b/src/endpoints/integrations/index.ts new file mode 100644 index 0000000..b94b5a7 --- /dev/null +++ b/src/endpoints/integrations/index.ts @@ -0,0 +1,40 @@ +import type { Headers } from '../../core/types/internal/http.ts'; +import type { RequestHandler } from '../../core/request-handler.ts'; +import type { HttpResponse } from '../../core/types/internal/http.ts'; + +/** + * This class provides a method to trigger an inbound integration by sending a payload + * to a specified URL. + * The term "inbound" is relative to xMatters, meaning that these integrations + * are designed to receive data from external systems into xMatters. + */ +export class IntegrationsEndpoint { + constructor( + private readonly http: RequestHandler, + ) {} + + /** + * Trigger an event by sending a POST request to an inbound integration URL, + * which you can obtain from an inbound integration. + * Inbound integration URLs use the following patterns: + * POST /api/integration/1/functions/{id}/triggers + * POST /api/integration/1/functions/{id}/triggers?apiKey={apiKey} + * + * @param url The URL of the integration trigger endpoint + * @param payload The payload to send to the integration + * @returns The HTTP response containing a paginated list of integrations + * @throws {XmApiError} If the request fails + */ + trigger( + url: string, + payload: unknown, + options: { headers?: Headers } = {}, + ): Promise> { + return this.http.post<{ requestId: string }>({ + ...options, + fullUrl: url, + body: payload, + skipAuth: true, + }); + } +} diff --git a/src/endpoints/people/index.ts b/src/endpoints/people/index.ts index 7b5a3a5..6e869c7 100644 --- a/src/endpoints/people/index.ts +++ b/src/endpoints/people/index.ts @@ -6,11 +6,7 @@ import type { PaginatedHttpResponse, PaginatedResponse, } from '../../core/types/endpoint/response.ts'; -import type { - DeleteOptions, - GetOptions, - RequestWithBodyOptions, -} from '../../core/types/internal/http-methods.ts'; +import type { Options } from '../../core/types/internal/request-building-options.ts'; import type { CreatePerson, GetPersonParams, @@ -39,7 +35,7 @@ export class PersonsEndpoint { * @throws {XmApiError} If the request fails */ get( - options?: Omit & { path?: string; query?: GetPersonsParams }, + options?: Options & { query?: GetPersonsParams }, ): Promise> { return this.http.get>(options); } @@ -54,7 +50,7 @@ export class PersonsEndpoint { */ getByIdentifier( identifier: string, - options?: Omit & { query?: GetPersonParams }, + options?: Options & { query?: GetPersonParams }, ): Promise> { return this.http.get({ ...options, path: identifier }); } @@ -63,29 +59,29 @@ export class PersonsEndpoint { * Create a new person or update an existing one * * @param person The person to create or update - * @param overrides Optional request overrides like custom headers + * @param options Optional request options such as custom headers * @returns The HTTP response containing the created or updated person * @throws {XmApiError} If the request fails */ save( person: CreatePerson | UpdatePerson, - overrides?: Omit, + options?: Options, ): Promise> { - return this.http.post({ ...overrides, body: person }); + return this.http.post({ ...options, body: person }); } /** * Delete a person by ID * * @param id The ID of the person to delete - * @param overrides Optional request overrides like custom headers + * @param options Optional request options such as custom headers * @returns The HTTP response * @throws {XmApiError} If the request fails */ delete( id: string, - overrides?: Omit, + options?: Options, ): Promise { - return this.http.delete({ ...overrides, path: id }); + return this.http.delete({ ...options, path: id }); } } diff --git a/src/index.ts b/src/index.ts index 89e5108..1a2b496 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,8 @@ -import { RequestHandler } from './core/request-handler.ts'; -import { validateConfig } from './core/utils/index.ts'; import type { XmApiConfig } from './core/types/internal/config.ts'; +import { validateConfig } from './core/utils/index.ts'; +import { RequestHandler } from './core/request-handler.ts'; import { GroupsEndpoint } from './endpoints/groups/index.ts'; +import { IntegrationsEndpoint } from './endpoints/integrations/index.ts'; import { OAuthEndpoint } from './endpoints/oauth/index.ts'; import { PersonsEndpoint } from './endpoints/people/index.ts'; @@ -13,6 +14,7 @@ export class XmApi { /** HTTP handler that manages all API requests */ private readonly http: RequestHandler; public readonly groups: GroupsEndpoint; + public readonly integrations: IntegrationsEndpoint; public readonly oauth: OAuthEndpoint; public readonly people: PersonsEndpoint; @@ -22,6 +24,7 @@ export class XmApi { this.http = new RequestHandler(config); // Initialize endpoints this.groups = new GroupsEndpoint(this.http); + this.integrations = new IntegrationsEndpoint(this.http); this.oauth = new OAuthEndpoint(this.http); this.people = new PersonsEndpoint(this.http); } From 76b060516e0a355ef7a663b696a4db20c6378a06 Mon Sep 17 00:00:00 2001 From: johan Date: Sat, 5 Jul 2025 08:19:32 -0700 Subject: [PATCH 088/101] Goldplating --- .vscode/typescript.code-snippets | 32 +++++-- deno.json | 3 +- deno.lock | 32 ------- src/core/defaults/axios-adapter.ts | 2 +- src/core/defaults/http-client.ts | 2 +- src/core/defaults/logger.ts | 2 +- src/core/errors.ts | 2 +- src/core/request-builder.test.ts | 4 +- src/core/request-builder.ts | 6 +- src/core/request-handler.ts | 19 ++-- src/core/resource-client.test.ts | 4 +- src/core/resource-client.ts | 4 +- src/core/test-utils.ts | 33 ++----- src/core/types/{internal => }/config.ts | 0 src/core/types/endpoint/response.ts | 62 ------------ src/core/types/{internal => }/http.ts | 94 +++++++++++++++---- .../{internal => }/mutable-auth-state.ts | 0 src/core/types/{internal => }/oauth.ts | 0 src/core/types/{endpoint => }/query-params.ts | 0 .../request-building-options.ts | 2 +- src/core/utils/config-validation.ts | 4 +- src/endpoints/groups/index.test.ts | 2 +- src/endpoints/groups/index.ts | 10 +- src/endpoints/groups/types.ts | 2 +- src/endpoints/integrations/index.ts | 4 +- src/endpoints/oauth/index.ts | 8 +- src/endpoints/people/index.test.ts | 2 +- src/endpoints/people/index.ts | 15 +-- src/endpoints/people/types.ts | 2 +- src/index.test.ts | 2 +- src/index.ts | 11 ++- 31 files changed, 157 insertions(+), 208 deletions(-) rename src/core/types/{internal => }/config.ts (100%) delete mode 100644 src/core/types/endpoint/response.ts rename src/core/types/{internal => }/http.ts (55%) rename src/core/types/{internal => }/mutable-auth-state.ts (100%) rename src/core/types/{internal => }/oauth.ts (100%) rename src/core/types/{endpoint => }/query-params.ts (100%) rename src/core/types/{internal => }/request-building-options.ts (96%) diff --git a/.vscode/typescript.code-snippets b/.vscode/typescript.code-snippets index 0c2f866..6c2f7e0 100644 --- a/.vscode/typescript.code-snippets +++ b/.vscode/typescript.code-snippets @@ -4,13 +4,19 @@ "description": "Generate a complete endpoint index.ts file", "body": [ "import { ResourceClient } from '../../core/resource-client.ts';", + "import type {", + " Create${1:Resource},", + " Get${1:Resource}Params,", + " Get${1:Resource}sParams,", + " ${1:Resource},", + " Update${1:Resource},", + "} from './types.ts';", + "import type { HttpResponse, PaginatedHttpResponse, PaginatedResponse } from 'types/http.ts';", + "import type { Options } from 'types/request-building-options.ts';", "import type { RequestHandler } from '../../core/request-handler.ts';", - "import type { PaginatedResponse } from '../../core/types/endpoint/response.ts';", - "import type { Options } from '../../core/types/internal/request-building-options.ts';", - "import type { Get${1:Resource}Params, Get${1:Resource}sParams, ${1:Resource} } from './types.ts';", "", "/**", - " * Provides access to the ${2:${1/(.*)/${1:/downcase}/}} endpoints of the xMatters API.", + " * Provides access to the ${2:${1/(.*)/${1:/downcase}/}}s endpoints of the xMatters API.", " * Use this class to manage ${2:${1/(.*)/${1:/downcase}/}}s, including listing, creating, updating, and deleting ${2:${1/(.*)/${1:/downcase}/}}s.", " */", "export class ${1:Resource}sEndpoint {", @@ -28,7 +34,9 @@ " * @returns The HTTP response containing a paginated list of ${2:${1/(.*)/${1:/downcase}/}}s", " * @throws {XmApiError} If the request fails", " */", - " get(options?: Options & { query?: Get${1:Resource}sParams }) {", + " get(", + " options?: Options & { query?: Get${1:Resource}sParams },", + " ): Promise> {", " return this.http.get>(options);", " }", "", @@ -40,7 +48,10 @@ " * @returns The HTTP response containing the ${2:${1/(.*)/${1:/downcase}/}}", " * @throws {XmApiError} If the request fails", " */", - " getByIdentifier(identifier: string, options?: Options & { query?: Get${1:Resource}Params }) {", + " getByIdentifier(", + " identifier: string,", + " options?: Options & { query?: Get${1:Resource}Params },", + " ): Promise> {", " return this.http.get<${1:Resource}>({ ...options, path: identifier });", " }", "", @@ -52,7 +63,10 @@ " * @returns The HTTP response containing the created or updated ${2:${1/(.*)/${1:/downcase}/}}", " * @throws {XmApiError} If the request fails", " */", - " save(${2:${1/(.*)/${1:/downcase}/}}: Partial<${1:Resource}>, options?: Options) {", + " save(", + " ${2:${1/(.*)/${1:/downcase}/}}: Create${1:Resource} | Update${1:Resource},", + " options?: Options,", + " ): Promise> {", " return this.http.post<${1:Resource}>({ ...options, body: ${2:${1/(.*)/${1:/downcase}/}} });", " }", "", @@ -64,8 +78,8 @@ " * @returns The HTTP response", " * @throws {XmApiError} If the request fails", " */", - " delete(id: string, options?: Options) {", - " return this.http.delete({ ...options, path: id });", + " delete(id: string, options?: Options): Promise> {", + " return this.http.delete<${1:Resource}>({ ...options, path: id });", " }", "}", "$0" diff --git a/deno.json b/deno.json index 6e6c2f3..498e31d 100644 --- a/deno.json +++ b/deno.json @@ -3,7 +3,8 @@ "version": "0.0.1", "exports": "./src/index.ts", "imports": { - "std/": "https://deno.land/std@0.224.0/" + "std/": "https://deno.land/std@0.224.0/", + "types/": "./src/core/types/" }, "tasks": { "cache": "DENO_TLS_CA_STORE=system deno cache --reload src/**/*.ts", diff --git a/deno.lock b/deno.lock index e99d24b..f7a09f1 100644 --- a/deno.lock +++ b/deno.lock @@ -1,50 +1,19 @@ { "version": "5", - "redirects": { - "https://deno.land/std/expect/mod.ts": "https://deno.land/std@0.224.0/expect/mod.ts" - }, "remote": { - "https://deno.land/std@0.193.0/async/delay.ts": "73aa04cec034c84fc748c7be49bb15cac3dd43a57174bfdb7a4aec22c248f0dd", - "https://deno.land/std@0.193.0/collections/_comparators.ts": "fa7f9a44cea1d270098a2a5a6f8bb30c61b595c1b1f983bd67c6297d766adffa", - "https://deno.land/std@0.193.0/collections/binary_search_node.ts": "8d99dd95901d73a0edbe105826ef7ce0e1111ce184d2d0410dbfda172c9ebf35", - "https://deno.land/std@0.193.0/collections/binary_search_tree.ts": "47b5d09bf6567a674918dd760ba62e2fb250552580361460fa9c58a399c9f3af", - "https://deno.land/std@0.193.0/collections/red_black_node.ts": "eb766a69d82132fc4f1789eb3dc753781da7c3b0938756256be3764c9941e3ac", - "https://deno.land/std@0.193.0/collections/red_black_tree.ts": "9dade0abb93cdb7cfd978dcdd01fe6f1bb3f14fdb4e54502367d3029edca01ec", - "https://deno.land/std@0.193.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e", - "https://deno.land/std@0.193.0/testing/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", - "https://deno.land/std@0.193.0/testing/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", - "https://deno.land/std@0.193.0/testing/_time.ts": "fecaf6fc7277d240d11b0de2e93b1c93ebbb4a3a61f0cb0b1741f66f69a4d22b", - "https://deno.land/std@0.193.0/testing/asserts.ts": "056d571baaefc7f13af3e29ad6a66d4dbe5355d3cb2ae130e7d2a1b1e01085e3", - "https://deno.land/std@0.193.0/testing/time.ts": "a46fbfd61e6f011f15a63c8078399b1f7fa848d2c0c526f253b0535f5c3e7f45", "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", - "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", - "https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", - "https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7", "https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74", - "https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd", "https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff", - "https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46", - "https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b", "https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c", "https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491", - "https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68", - "https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3", "https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7", - "https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29", "https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a", "https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a", "https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8", "https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693", - "https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31", "https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5", - "https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8", - "https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb", "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", "https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47", - "https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68", - "https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3", - "https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73", - "https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19", "https://deno.land/std@0.224.0/async/delay.ts": "f90dd685b97c2f142b8069082993e437b1602b8e2561134827eeb7c12b95c499", "https://deno.land/std@0.224.0/data_structures/_binary_search_node.ts": "ce1da11601fef0638df4d1e53c377f791f96913383277389286b390685d76c07", "https://deno.land/std@0.224.0/data_structures/_red_black_node.ts": "4af8d3c5ac5f119d8058269259c46ea22ead567246cacde04584a83e43a9d2ea", @@ -73,7 +42,6 @@ "https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2", "https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e", "https://deno.land/std@0.224.0/testing/_time.ts": "fefd1ff35b50a410db9b0e7227e05163e1b172c88afd0d2071df0125958c3ff3", - "https://deno.land/std@0.224.0/testing/mock.ts": "a963181c2860b6ba3eb60e08b62c164d33cf5da7cd445893499b2efda20074db", "https://deno.land/std@0.224.0/testing/time.ts": "7119072a198e9913da0d21106b1f05a90a4c05b07075529770ff0e2a9eb5eaba" } } diff --git a/src/core/defaults/axios-adapter.ts b/src/core/defaults/axios-adapter.ts index e3da1ff..195bc94 100644 --- a/src/core/defaults/axios-adapter.ts +++ b/src/core/defaults/axios-adapter.ts @@ -1,4 +1,4 @@ -import type { Headers, HttpClient, HttpRequest, HttpResponse } from '../types/internal/http.ts'; +import type { Headers, HttpClient, HttpRequest, HttpResponse } from 'types/http.ts'; // Minimal interface for what we need from an axios instance interface AxiosLike { diff --git a/src/core/defaults/http-client.ts b/src/core/defaults/http-client.ts index 4dd6865..4260c05 100644 --- a/src/core/defaults/http-client.ts +++ b/src/core/defaults/http-client.ts @@ -1,4 +1,4 @@ -import type { Headers, HttpClient, HttpRequest, HttpResponse } from '../types/internal/http.ts'; +import type { Headers, HttpClient, HttpRequest, HttpResponse } from 'types/http.ts'; export class DefaultHttpClient implements HttpClient { async send(request: HttpRequest): Promise { diff --git a/src/core/defaults/logger.ts b/src/core/defaults/logger.ts index 2336521..5161919 100644 --- a/src/core/defaults/logger.ts +++ b/src/core/defaults/logger.ts @@ -1,4 +1,4 @@ -import type { Logger } from '../types/internal/config.ts'; +import type { Logger } from 'types/config.ts'; export const defaultLogger: Logger = { debug: console.debug.bind(console), diff --git a/src/core/errors.ts b/src/core/errors.ts index 4cdea21..c0f2acd 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -1,4 +1,4 @@ -import type { HttpResponse } from './types/internal/http.ts'; +import type { HttpResponse } from 'types/http.ts'; /** * Base class for all errors thrown by the xMatters API client. diff --git a/src/core/request-builder.test.ts b/src/core/request-builder.test.ts index 76c9dd6..af3b4c9 100644 --- a/src/core/request-builder.test.ts +++ b/src/core/request-builder.test.ts @@ -1,8 +1,8 @@ import { expect } from 'std/expect/mod.ts'; import { RequestBuilder } from './request-builder.ts'; -import type { RequestBuildingOptions } from './types/internal/request-building-options.ts'; -import type { Headers } from './types/internal/http.ts'; import { XmApiError } from './errors.ts'; +import type { Headers } from 'types/http.ts'; +import type { RequestBuildingOptions } from 'types/request-building-options.ts'; // Test helper to create RequestBuilder with standard configuration function createRequestBuilderTestSetup(options: { diff --git a/src/core/request-builder.ts b/src/core/request-builder.ts index 1549083..4f34562 100644 --- a/src/core/request-builder.ts +++ b/src/core/request-builder.ts @@ -1,7 +1,7 @@ import { XmApiError } from './errors.ts'; -import type { Headers, HttpRequest } from './types/internal/http.ts'; -import type { QueryParams } from './types/endpoint/query-params.ts'; -import type { RequestBuildingOptions } from './types/internal/request-building-options.ts'; +import type { Headers, HttpRequest } from 'types/http.ts'; +import type { QueryParams } from 'types/query-params.ts'; +import type { RequestBuildingOptions } from 'types/request-building-options.ts'; export class RequestBuilder { private readonly apiVersionPath = '/api/xm/1'; diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index ab81ddd..b3638a3 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -1,4 +1,7 @@ -import type { Headers, HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; +import { DefaultHttpClient, defaultLogger } from './defaults/index.ts'; +import { RequestBuilder } from './request-builder.ts'; +import { XmApiError } from './errors.ts'; +import { AuthType, type MutableAuthState } from 'types/mutable-auth-state.ts'; import { isAuthCodeConfig, isBasicAuthConfig, @@ -6,17 +9,11 @@ import { type Logger, type TokenRefreshCallback, type XmApiConfig, -} from './types/internal/config.ts'; -import { AuthType, type MutableAuthState } from './types/internal/mutable-auth-state.ts'; -import { XmApiError } from './errors.ts'; -import type { OAuth2TokenResponse } from './types/internal/oauth.ts'; -import { RequestBuilder } from './request-builder.ts'; -import type { - RequestBuildingOptions, - RequestOptions, -} from './types/internal/request-building-options.ts'; -import { DefaultHttpClient, defaultLogger } from './defaults/index.ts'; +} from 'types/config.ts'; import denoJson from '../../deno.json' with { type: 'json' }; +import type { Headers, HttpClient, HttpRequest, HttpResponse } from 'types/http.ts'; +import type { OAuth2TokenResponse } from 'types/oauth.ts'; +import type { RequestBuildingOptions, RequestOptions } from 'types/request-building-options.ts'; export class RequestHandler { /** HTTP client for making requests */ diff --git a/src/core/resource-client.test.ts b/src/core/resource-client.test.ts index c852717..8c84446 100644 --- a/src/core/resource-client.test.ts +++ b/src/core/resource-client.test.ts @@ -1,8 +1,8 @@ import { expect } from 'std/expect/mod.ts'; -import { ResourceClient } from './resource-client.ts'; +import { MockHttpClient, MockLogger } from './test-utils.ts'; import { RequestHandler } from './request-handler.ts'; +import { ResourceClient } from './resource-client.ts'; import { XmApiError } from './errors.ts'; -import { MockHttpClient, MockLogger } from './test-utils.ts'; // Helper to create ResourceClient with mock dependencies function createResourceClientTestSetup(basePath: string) { diff --git a/src/core/resource-client.ts b/src/core/resource-client.ts index 3c92bc3..cfa0f44 100644 --- a/src/core/resource-client.ts +++ b/src/core/resource-client.ts @@ -1,6 +1,6 @@ -import type { ResourceOptions } from './types/internal/request-building-options.ts'; -import type { RequestHandler } from './request-handler.ts'; import { XmApiError } from './errors.ts'; +import type { RequestHandler } from './request-handler.ts'; +import type { ResourceOptions } from 'types/request-building-options.ts'; /** * A wrapper around RequestHandler that automatically prepends a base path to all requests. diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts index e890242..3f019a7 100644 --- a/src/core/test-utils.ts +++ b/src/core/test-utils.ts @@ -1,28 +1,14 @@ -/** - * @fileoverview Minimal test utilities for xMatters API library - * - * This module provides the bare essentials for testing: - * - Mock HTTP client that prevents network calls and tracks requests - * - Mock logger using Deno's stub functionality - * - * Testing Philosophy: - * - Keep it SIMPLE and RELIABLE - * - No HTTP requests should go over the wire during tests - * - Mock only at the HTTP client boundary - * - Test authors specify exact request-response pairs for predictability - */ - -import type { HttpClient, HttpRequest, HttpResponse } from './types/internal/http.ts'; -import type { Logger } from './types/internal/config.ts'; -import { FakeTime } from 'std/testing/time.ts'; import { expect } from 'std/expect/mod.ts'; +import { FakeTime } from 'std/testing/time.ts'; +import type { HttpClient, HttpRequest, HttpResponse } from 'types/http.ts'; +import type { Logger } from 'types/config.ts'; /** * Request-response pair for testing - HTTP response case * Set up expected requests and their mocked HTTP responses (any status code). */ interface MockRequestWithResponse { - expectedRequest: Partial; + expectedRequest: HttpRequest; mockedResponse: Partial; } @@ -34,8 +20,8 @@ interface MockRequestWithResponse { * (!) Not to be confused with HTTP error responses * (like 404, 500, etc.) which are still HTTP responses. */ -interface MockRequestError { - expectedRequest: Partial; +interface MockRequestWithError { + expectedRequest: HttpRequest; mockedError: Error; } @@ -43,7 +29,7 @@ interface MockRequestError { * Request-response pair for testing * Set up expected requests and their mocked responses or errors. */ -type MockRequestResponse = MockRequestWithResponse | MockRequestError; +type MockRequestResponse = MockRequestWithResponse | MockRequestWithError; /** * Mock HTTP client that prevents network calls during tests. @@ -64,12 +50,10 @@ export class MockHttpClient implements HttpClient { } const currentPair = this.requestResponsePairs[this.requests.length - 1]; this.validateRequest(request, currentPair.expectedRequest); - // Handle error case if ('mockedError' in currentPair) { return Promise.reject(currentPair.mockedError); } - // Validate response case has required response if (!('mockedResponse' in currentPair) || !currentPair.mockedResponse) { return Promise.reject( @@ -78,7 +62,6 @@ export class MockHttpClient implements HttpClient { ), ); } - // Handle response case const response: HttpResponse = { status: currentPair.mockedResponse.status || 200, @@ -115,7 +98,7 @@ export class MockHttpClient implements HttpClient { private validateRequest( actualRequest: HttpRequest, - expectedRequest: Partial, + expectedRequest: HttpRequest, ): void { expect(actualRequest.method).toBe(expectedRequest.method); expect(actualRequest.url).toBe(expectedRequest.url); diff --git a/src/core/types/internal/config.ts b/src/core/types/config.ts similarity index 100% rename from src/core/types/internal/config.ts rename to src/core/types/config.ts diff --git a/src/core/types/endpoint/response.ts b/src/core/types/endpoint/response.ts deleted file mode 100644 index 5764883..0000000 --- a/src/core/types/endpoint/response.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Response wrapper types for endpoint implementations. - * These provide standardized response shapes that endpoints can use. - */ - -import type { HttpResponse } from '../internal/http.ts'; - -/** - * Common response wrapper for paginated lists - */ -export interface PaginatedResponse { - /** Number of items in this response */ - count: number; - /** Total number of items available */ - total: number; - /** The items for this page */ - data: T[]; - /** HAL links for navigation */ - links?: { - /** URL to current page */ - self: string; - /** URL to next page, if available */ - next?: string; - /** URL to previous page, if available */ - prev?: string; - }; -} - -/** - * Type alias for HTTP responses containing paginated data. - * Use this for endpoint methods that return paginated lists. - * - * @template T The type of items in the paginated response - * - * @example - * ```typescript - * // Instead of: Promise> - * // Use: Promise> - * get(): Promise> { - * return this.http.get>({ path: '/groups' }); - * } - * ``` - */ -export type PaginatedHttpResponse = HttpResponse>; - -// Note: For single resource responses, use HttpResponse directly -// Example: Promise> instead of creating an unnecessary alias - -/** - * Type alias for HTTP responses that don't return a body (like delete operations). - * Use this for endpoint methods that perform actions without returning data. - * - * @example - * ```typescript - * // Instead of: Promise - * // Use: Promise - * delete(id: string): Promise { - * return this.http.delete({ path: `/${id}` }); - * } - * ``` - */ -export type EmptyHttpResponse = HttpResponse; diff --git a/src/core/types/internal/http.ts b/src/core/types/http.ts similarity index 55% rename from src/core/types/internal/http.ts rename to src/core/types/http.ts index 57f1211..5892ad9 100644 --- a/src/core/types/internal/http.ts +++ b/src/core/types/http.ts @@ -1,26 +1,8 @@ -/** - * Core HTTP types used internally by the library. - * These types define the shape of requests and responses handled by the HTTP layer. - */ - /** * HTTP headers as key-value pairs */ export type Headers = Record; -/** - * Represents an HTTP response from the xMatters API. - * @template T The expected type of the response body - */ -export interface HttpResponse { - /** The parsed response body */ - body: T; - /** The HTTP status code */ - status: number; - /** Response headers */ - headers?: Headers; -} - /** * Represents a fully-prepared HTTP request ready to be sent. * This interface is designed to work with any HTTP client implementation. @@ -35,10 +17,23 @@ export interface HttpRequest { headers?: Headers; /** Optional request body (injected HTTP client should handle serialization) */ body?: unknown; - /** Current retry attempt number (used by retry mechanism; available for logging/debugging in HTTP clients) */ + /** Current retry attempt number (used by retry mechanism; available in HTTP clients for logging/debugging) */ retryAttempt?: number; } +/** + * Represents an HTTP response from the xMatters API. + * @template T The expected type of the response body + */ +export interface HttpResponse { + /** The parsed response body */ + body: T; + /** The HTTP status code */ + status: number; + /** Response headers */ + headers?: Headers; +} + /** * Interface that HTTP clients must implement to be used with this library. * @@ -65,3 +60,64 @@ export interface HttpClient { */ send: (request: HttpRequest) => Promise; } + +/** + * Response wrapper types for endpoint implementations. + * These provide standardized response shapes that endpoints can use. + */ + +/** + * Common response wrapper for paginated lists + */ +export interface PaginatedResponse { + /** Number of items in this response */ + count: number; + /** Total number of items available */ + total: number; + /** The items for this page */ + data: T[]; + /** HAL links for navigation */ + links?: { + /** URL to current page */ + self: string; + /** URL to next page, if available */ + next?: string; + /** URL to previous page, if available */ + prev?: string; + }; +} + +/** + * Type alias for HTTP responses containing paginated data. + * Use this for endpoint methods that return paginated lists. + * + * @template T The type of items in the paginated response + * + * @example + * ```typescript + * // Mind the difference between: + * // Promise> and Promise> + * get(): Promise> { + * return this.http.get>(); + * } + * ``` + */ +export type PaginatedHttpResponse = HttpResponse>; + +// Note: For single resource responses, use HttpResponse directly +// Example: Promise> instead of creating an unnecessary alias + +/** + * Type alias for HTTP responses that don't return a body (like delete operations). + * Use this for endpoint methods that perform actions without returning data. + * + * @example + * ```typescript + * // Mind the difference between: + * // Promise and Promise + * delete(id: string): Promise { + * return this.http.delete({ path: id }); + * } + * ``` + */ +export type EmptyHttpResponse = HttpResponse; diff --git a/src/core/types/internal/mutable-auth-state.ts b/src/core/types/mutable-auth-state.ts similarity index 100% rename from src/core/types/internal/mutable-auth-state.ts rename to src/core/types/mutable-auth-state.ts diff --git a/src/core/types/internal/oauth.ts b/src/core/types/oauth.ts similarity index 100% rename from src/core/types/internal/oauth.ts rename to src/core/types/oauth.ts diff --git a/src/core/types/endpoint/query-params.ts b/src/core/types/query-params.ts similarity index 100% rename from src/core/types/endpoint/query-params.ts rename to src/core/types/query-params.ts diff --git a/src/core/types/internal/request-building-options.ts b/src/core/types/request-building-options.ts similarity index 96% rename from src/core/types/internal/request-building-options.ts rename to src/core/types/request-building-options.ts index 32f2254..72ba603 100644 --- a/src/core/types/internal/request-building-options.ts +++ b/src/core/types/request-building-options.ts @@ -1,4 +1,4 @@ -import type { QueryParams } from '../endpoint/query-params.ts'; +import type { QueryParams } from './query-params.ts'; import type { Headers } from './http.ts'; /** diff --git a/src/core/utils/config-validation.ts b/src/core/utils/config-validation.ts index 9db3db7..ba44fc7 100644 --- a/src/core/utils/config-validation.ts +++ b/src/core/utils/config-validation.ts @@ -1,6 +1,6 @@ +import { isAuthCodeConfig, isBasicAuthConfig, isOAuthConfig } from 'types/config.ts'; import { XmApiError } from '../errors.ts'; -import type { XmApiConfig } from '../types/internal/config.ts'; -import { isAuthCodeConfig, isBasicAuthConfig, isOAuthConfig } from '../types/internal/config.ts'; +import type { XmApiConfig } from 'types/config.ts'; /** * Validates that a hostname is a valid xMatters hostname. diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index 9f96450..e827984 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -1,6 +1,6 @@ import { GroupsEndpoint } from './index.ts'; -import { RequestHandler } from '../../core/request-handler.ts'; import { MockHttpClient, MockLogger, TestConstants } from '../../core/test-utils.ts'; +import { RequestHandler } from '../../core/request-handler.ts'; // Shared test infrastructure - MockHttpClient auto-resets between tests const mockHttpClient = new MockHttpClient(); diff --git a/src/endpoints/groups/index.ts b/src/endpoints/groups/index.ts index 6d3211a..67a0070 100644 --- a/src/endpoints/groups/index.ts +++ b/src/endpoints/groups/index.ts @@ -1,11 +1,4 @@ import { ResourceClient } from '../../core/resource-client.ts'; -import type { RequestHandler } from '../../core/request-handler.ts'; -import type { HttpResponse } from '../../core/types/internal/http.ts'; -import type { - PaginatedHttpResponse, - PaginatedResponse, -} from '../../core/types/endpoint/response.ts'; -import type { Options } from '../../core/types/internal/request-building-options.ts'; import type { CreateGroup, GetGroupParams, @@ -14,7 +7,10 @@ import type { GroupQuotas, UpdateGroup, } from './types.ts'; +import type { HttpResponse, PaginatedHttpResponse, PaginatedResponse } from 'types/http.ts'; +import type { Options } from 'types/request-building-options.ts'; import type { Person } from '../people/types.ts'; +import type { RequestHandler } from '../../core/request-handler.ts'; /** * Provides access to the groups endpoints of the xMatters API. diff --git a/src/endpoints/groups/types.ts b/src/endpoints/groups/types.ts index 7a3e018..d7e4cad 100644 --- a/src/endpoints/groups/types.ts +++ b/src/endpoints/groups/types.ts @@ -4,7 +4,7 @@ import type { SearchParams, SortOrder, StatusParams, -} from '../../core/types/endpoint/query-params.ts'; +} from 'types/query-params.ts'; /** * Represents a group in xMatters. diff --git a/src/endpoints/integrations/index.ts b/src/endpoints/integrations/index.ts index b94b5a7..ef70056 100644 --- a/src/endpoints/integrations/index.ts +++ b/src/endpoints/integrations/index.ts @@ -1,6 +1,6 @@ -import type { Headers } from '../../core/types/internal/http.ts'; +import type { Headers } from 'types/http.ts'; +import type { HttpResponse } from 'types/http.ts'; import type { RequestHandler } from '../../core/request-handler.ts'; -import type { HttpResponse } from '../../core/types/internal/http.ts'; /** * This class provides a method to trigger an inbound integration by sending a payload diff --git a/src/endpoints/oauth/index.ts b/src/endpoints/oauth/index.ts index 063b6de..df1839f 100644 --- a/src/endpoints/oauth/index.ts +++ b/src/endpoints/oauth/index.ts @@ -1,8 +1,8 @@ -import type { RequestHandler } from '../../core/request-handler.ts'; -import type { HttpResponse } from '../../core/types/internal/http.ts'; -import type { OAuth2TokenResponse } from '../../core/types/internal/oauth.ts'; +import { AuthType } from 'types/mutable-auth-state.ts'; import { XmApiError } from '../../core/errors.ts'; -import { AuthType } from '../../core/types/internal/mutable-auth-state.ts'; +import type { HttpResponse } from 'types/http.ts'; +import type { OAuth2TokenResponse } from 'types/oauth.ts'; +import type { RequestHandler } from '../../core/request-handler.ts'; export class OAuthEndpoint { constructor( diff --git a/src/endpoints/people/index.test.ts b/src/endpoints/people/index.test.ts index 813e35e..d486a83 100644 --- a/src/endpoints/people/index.test.ts +++ b/src/endpoints/people/index.test.ts @@ -1,6 +1,6 @@ +import { MockHttpClient, MockLogger, TestConstants } from '../../core/test-utils.ts'; import { PersonsEndpoint } from './index.ts'; import { RequestHandler } from '../../core/request-handler.ts'; -import { MockHttpClient, MockLogger, TestConstants } from '../../core/test-utils.ts'; // Shared test infrastructure - MockHttpClient auto-resets between tests const mockHttpClient = new MockHttpClient(); diff --git a/src/endpoints/people/index.ts b/src/endpoints/people/index.ts index 6e869c7..c4a9554 100644 --- a/src/endpoints/people/index.ts +++ b/src/endpoints/people/index.ts @@ -1,12 +1,4 @@ import { ResourceClient } from '../../core/resource-client.ts'; -import type { RequestHandler } from '../../core/request-handler.ts'; -import type { HttpResponse } from '../../core/types/internal/http.ts'; -import type { - EmptyHttpResponse, - PaginatedHttpResponse, - PaginatedResponse, -} from '../../core/types/endpoint/response.ts'; -import type { Options } from '../../core/types/internal/request-building-options.ts'; import type { CreatePerson, GetPersonParams, @@ -14,6 +6,9 @@ import type { Person, UpdatePerson, } from './types.ts'; +import type { HttpResponse, PaginatedHttpResponse, PaginatedResponse } from 'types/http.ts'; +import type { Options } from 'types/request-building-options.ts'; +import type { RequestHandler } from '../../core/request-handler.ts'; /** * Provides access to the people endpoints of the xMatters API. @@ -81,7 +76,7 @@ export class PersonsEndpoint { delete( id: string, options?: Options, - ): Promise { - return this.http.delete({ ...options, path: id }); + ): Promise> { + return this.http.delete({ ...options, path: id }); } } diff --git a/src/endpoints/people/types.ts b/src/endpoints/people/types.ts index 6f91082..9b4f42a 100644 --- a/src/endpoints/people/types.ts +++ b/src/endpoints/people/types.ts @@ -4,7 +4,7 @@ import type { SearchParams, SortOrder, StatusParams, -} from '../../core/types/endpoint/query-params.ts'; +} from 'types/query-params.ts'; /** * Represents a person in xMatters. diff --git a/src/index.test.ts b/src/index.test.ts index a199958..1432e03 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,6 +1,6 @@ import { expect } from 'std/expect/mod.ts'; -import { XmApi, XmApiError } from './index.ts'; import { MockHttpClient, MockLogger, withFakeTime } from './core/test-utils.ts'; +import { XmApi, XmApiError } from './index.ts'; const mockHttpClient = new MockHttpClient(); const mockLogger = new MockLogger(); diff --git a/src/index.ts b/src/index.ts index 1a2b496..574a1e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,10 @@ -import type { XmApiConfig } from './core/types/internal/config.ts'; -import { validateConfig } from './core/utils/index.ts'; -import { RequestHandler } from './core/request-handler.ts'; import { GroupsEndpoint } from './endpoints/groups/index.ts'; import { IntegrationsEndpoint } from './endpoints/integrations/index.ts'; import { OAuthEndpoint } from './endpoints/oauth/index.ts'; import { PersonsEndpoint } from './endpoints/people/index.ts'; +import { RequestHandler } from './core/request-handler.ts'; +import { validateConfig } from './core/utils/index.ts'; +import type { XmApiConfig } from 'types/config.ts'; /** * Main entry point for the xMatters API client. @@ -13,6 +13,7 @@ import { PersonsEndpoint } from './endpoints/people/index.ts'; export class XmApi { /** HTTP handler that manages all API requests */ private readonly http: RequestHandler; + public readonly groups: GroupsEndpoint; public readonly integrations: IntegrationsEndpoint; public readonly oauth: OAuthEndpoint; @@ -32,8 +33,8 @@ export class XmApi { // Re-export only the types consumers need to implement // Dependency injection interfaces - consumers implement these -export type { Logger, TokenRefreshCallback } from './core/types/internal/config.ts'; -export type { HttpClient } from './core/types/internal/http.ts'; +export type { Logger, TokenRefreshCallback } from 'types/config.ts'; +export type { HttpClient } from 'types/http.ts'; // Export error class - consumers need to catch and handle these export { XmApiError } from './core/errors.ts'; // For convenience From 6dd861516804aca949a5332e3a729b383baad95f Mon Sep 17 00:00:00 2001 From: johan Date: Sat, 5 Jul 2025 08:39:24 -0700 Subject: [PATCH 089/101] Goldplating --- .vscode/typescript.code-snippets | 4 ++-- deno.json | 3 ++- src/endpoints/groups/index.test.ts | 4 ++-- src/endpoints/groups/index.ts | 4 ++-- src/endpoints/integrations/index.ts | 2 +- src/endpoints/oauth/index.ts | 4 ++-- src/endpoints/people/index.test.ts | 4 ++-- src/endpoints/people/index.ts | 4 ++-- src/index.test.ts | 2 +- src/index.ts | 4 ++-- 10 files changed, 18 insertions(+), 17 deletions(-) diff --git a/.vscode/typescript.code-snippets b/.vscode/typescript.code-snippets index 6c2f7e0..a42eaec 100644 --- a/.vscode/typescript.code-snippets +++ b/.vscode/typescript.code-snippets @@ -3,7 +3,7 @@ "prefix": "xm-endpoint", "description": "Generate a complete endpoint index.ts file", "body": [ - "import { ResourceClient } from '../../core/resource-client.ts';", + "import { ResourceClient } from 'core/resource-client.ts';", "import type {", " Create${1:Resource},", " Get${1:Resource}Params,", @@ -13,7 +13,7 @@ "} from './types.ts';", "import type { HttpResponse, PaginatedHttpResponse, PaginatedResponse } from 'types/http.ts';", "import type { Options } from 'types/request-building-options.ts';", - "import type { RequestHandler } from '../../core/request-handler.ts';", + "import type { RequestHandler } from 'core/request-handler.ts';", "", "/**", " * Provides access to the ${2:${1/(.*)/${1:/downcase}/}}s endpoints of the xMatters API.", diff --git a/deno.json b/deno.json index 498e31d..b33e191 100644 --- a/deno.json +++ b/deno.json @@ -4,7 +4,8 @@ "exports": "./src/index.ts", "imports": { "std/": "https://deno.land/std@0.224.0/", - "types/": "./src/core/types/" + "types/": "./src/core/types/", + "core/": "./src/core/" }, "tasks": { "cache": "DENO_TLS_CA_STORE=system deno cache --reload src/**/*.ts", diff --git a/src/endpoints/groups/index.test.ts b/src/endpoints/groups/index.test.ts index e827984..9145cc3 100644 --- a/src/endpoints/groups/index.test.ts +++ b/src/endpoints/groups/index.test.ts @@ -1,6 +1,6 @@ import { GroupsEndpoint } from './index.ts'; -import { MockHttpClient, MockLogger, TestConstants } from '../../core/test-utils.ts'; -import { RequestHandler } from '../../core/request-handler.ts'; +import { MockHttpClient, MockLogger, TestConstants } from 'core/test-utils.ts'; +import { RequestHandler } from 'core/request-handler.ts'; // Shared test infrastructure - MockHttpClient auto-resets between tests const mockHttpClient = new MockHttpClient(); diff --git a/src/endpoints/groups/index.ts b/src/endpoints/groups/index.ts index 67a0070..f094630 100644 --- a/src/endpoints/groups/index.ts +++ b/src/endpoints/groups/index.ts @@ -1,4 +1,4 @@ -import { ResourceClient } from '../../core/resource-client.ts'; +import { ResourceClient } from 'core/resource-client.ts'; import type { CreateGroup, GetGroupParams, @@ -10,7 +10,7 @@ import type { import type { HttpResponse, PaginatedHttpResponse, PaginatedResponse } from 'types/http.ts'; import type { Options } from 'types/request-building-options.ts'; import type { Person } from '../people/types.ts'; -import type { RequestHandler } from '../../core/request-handler.ts'; +import type { RequestHandler } from 'core/request-handler.ts'; /** * Provides access to the groups endpoints of the xMatters API. diff --git a/src/endpoints/integrations/index.ts b/src/endpoints/integrations/index.ts index ef70056..a6e9aff 100644 --- a/src/endpoints/integrations/index.ts +++ b/src/endpoints/integrations/index.ts @@ -1,6 +1,6 @@ import type { Headers } from 'types/http.ts'; import type { HttpResponse } from 'types/http.ts'; -import type { RequestHandler } from '../../core/request-handler.ts'; +import type { RequestHandler } from 'core/request-handler.ts'; /** * This class provides a method to trigger an inbound integration by sending a payload diff --git a/src/endpoints/oauth/index.ts b/src/endpoints/oauth/index.ts index df1839f..5ea410a 100644 --- a/src/endpoints/oauth/index.ts +++ b/src/endpoints/oauth/index.ts @@ -1,8 +1,8 @@ import { AuthType } from 'types/mutable-auth-state.ts'; -import { XmApiError } from '../../core/errors.ts'; +import { XmApiError } from 'core/errors.ts'; import type { HttpResponse } from 'types/http.ts'; import type { OAuth2TokenResponse } from 'types/oauth.ts'; -import type { RequestHandler } from '../../core/request-handler.ts'; +import type { RequestHandler } from 'core/request-handler.ts'; export class OAuthEndpoint { constructor( diff --git a/src/endpoints/people/index.test.ts b/src/endpoints/people/index.test.ts index d486a83..667ac36 100644 --- a/src/endpoints/people/index.test.ts +++ b/src/endpoints/people/index.test.ts @@ -1,6 +1,6 @@ -import { MockHttpClient, MockLogger, TestConstants } from '../../core/test-utils.ts'; +import { MockHttpClient, MockLogger, TestConstants } from 'core/test-utils.ts'; import { PersonsEndpoint } from './index.ts'; -import { RequestHandler } from '../../core/request-handler.ts'; +import { RequestHandler } from 'core/request-handler.ts'; // Shared test infrastructure - MockHttpClient auto-resets between tests const mockHttpClient = new MockHttpClient(); diff --git a/src/endpoints/people/index.ts b/src/endpoints/people/index.ts index c4a9554..f60a5c0 100644 --- a/src/endpoints/people/index.ts +++ b/src/endpoints/people/index.ts @@ -1,4 +1,4 @@ -import { ResourceClient } from '../../core/resource-client.ts'; +import { ResourceClient } from 'core/resource-client.ts'; import type { CreatePerson, GetPersonParams, @@ -8,7 +8,7 @@ import type { } from './types.ts'; import type { HttpResponse, PaginatedHttpResponse, PaginatedResponse } from 'types/http.ts'; import type { Options } from 'types/request-building-options.ts'; -import type { RequestHandler } from '../../core/request-handler.ts'; +import type { RequestHandler } from 'core/request-handler.ts'; /** * Provides access to the people endpoints of the xMatters API. diff --git a/src/index.test.ts b/src/index.test.ts index 1432e03..502f6ac 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,5 +1,5 @@ import { expect } from 'std/expect/mod.ts'; -import { MockHttpClient, MockLogger, withFakeTime } from './core/test-utils.ts'; +import { MockHttpClient, MockLogger, withFakeTime } from 'core/test-utils.ts'; import { XmApi, XmApiError } from './index.ts'; const mockHttpClient = new MockHttpClient(); diff --git a/src/index.ts b/src/index.ts index 574a1e9..f81be21 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,8 @@ import { GroupsEndpoint } from './endpoints/groups/index.ts'; import { IntegrationsEndpoint } from './endpoints/integrations/index.ts'; import { OAuthEndpoint } from './endpoints/oauth/index.ts'; import { PersonsEndpoint } from './endpoints/people/index.ts'; -import { RequestHandler } from './core/request-handler.ts'; -import { validateConfig } from './core/utils/index.ts'; +import { RequestHandler } from 'core/request-handler.ts'; +import { validateConfig } from 'core/utils/index.ts'; import type { XmApiConfig } from 'types/config.ts'; /** From 8e6a6071c72dc3ab06c2f52e706710faf91f6391 Mon Sep 17 00:00:00 2001 From: johan Date: Sat, 5 Jul 2025 08:45:41 -0700 Subject: [PATCH 090/101] Goldplating --- src/index.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index f81be21..02b73ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,11 +31,10 @@ export class XmApi { } } -// Re-export only the types consumers need to implement -// Dependency injection interfaces - consumers implement these +// Re-export only the types consumers need for configuration export type { Logger, TokenRefreshCallback } from 'types/config.ts'; export type { HttpClient } from 'types/http.ts'; // Export error class - consumers need to catch and handle these -export { XmApiError } from './core/errors.ts'; +export { XmApiError } from 'core/errors.ts'; // For convenience -export { axiosAdapter } from './core/defaults/index.ts'; +export { axiosAdapter } from 'core/defaults/index.ts'; From 43fa43e55e19e3641d52a11692fbe284544b1544 Mon Sep 17 00:00:00 2001 From: johan Date: Sat, 5 Jul 2025 10:14:47 -0700 Subject: [PATCH 091/101] Goldplating --- src/core/resource-client.test.ts | 116 ++------------ src/core/test-utils.ts | 10 +- src/index.test.ts | 251 ++++++------------------------- 3 files changed, 61 insertions(+), 316 deletions(-) diff --git a/src/core/resource-client.test.ts b/src/core/resource-client.test.ts index 8c84446..0fafe85 100644 --- a/src/core/resource-client.test.ts +++ b/src/core/resource-client.test.ts @@ -1,5 +1,5 @@ import { expect } from 'std/expect/mod.ts'; -import { MockHttpClient, MockLogger } from './test-utils.ts'; +import { MockHttpClient, MockLogger, TestConstants } from './test-utils.ts'; import { RequestHandler } from './request-handler.ts'; import { ResourceClient } from './resource-client.ts'; import { XmApiError } from './errors.ts'; @@ -11,9 +11,7 @@ function createResourceClientTestSetup(basePath: string) { const requestHandler = new RequestHandler({ httpClient: mockHttpClient, logger: mockLogger, - hostname: 'https://test.xmatters.com', - username: 'testuser', - password: 'testpass', + ...TestConstants.BASIC_CONFIG, }); return { mockHttpClient, @@ -53,24 +51,13 @@ Deno.test('ResourceClient', async (t) => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups/members', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.BASIC_AUTH_HEADERS, }, mockedResponse: { - status: 200, - headers: { 'content-type': 'application/json' }, body: { success: true }, }, }]); await client.get({ path: 'members' }); - expect(mockHttpClient.requests).toHaveLength(1); - expect(mockHttpClient.requests[0].url).toBe( - 'https://test.xmatters.com/api/xm/1/groups/members', - ); mockHttpClient.verifyAllRequestsMade(); }); @@ -81,22 +68,13 @@ Deno.test('ResourceClient', async (t) => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.BASIC_AUTH_HEADERS, }, mockedResponse: { - status: 200, - headers: { 'content-type': 'application/json' }, body: { success: true }, }, }]); await client.get({}); - expect(mockHttpClient.requests).toHaveLength(1); - expect(mockHttpClient.requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups'); mockHttpClient.verifyAllRequestsMade(); }); @@ -107,24 +85,13 @@ Deno.test('ResourceClient', async (t) => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups/members', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.BASIC_AUTH_HEADERS, }, mockedResponse: { - status: 200, - headers: { 'content-type': 'application/json' }, body: { success: true }, }, }]); await client.get({ path: '/members' }); - expect(mockHttpClient.requests).toHaveLength(1); - expect(mockHttpClient.requests[0].url).toBe( - 'https://test.xmatters.com/api/xm/1/groups/members', - ); mockHttpClient.verifyAllRequestsMade(); }); }); @@ -137,17 +104,11 @@ Deno.test('ResourceClient', async (t) => { expectedRequest: { method: 'POST', url: 'https://test.xmatters.com/api/xm/1/groups/new-group', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.BASIC_AUTH_HEADERS, body: { name: 'Test Group' }, }, mockedResponse: { status: 201, - headers: { 'content-type': 'application/json' }, body: { id: '123' }, }, }]); @@ -155,11 +116,6 @@ Deno.test('ResourceClient', async (t) => { path: 'new-group', body: { name: 'Test Group' }, }); - expect(mockHttpClient.requests).toHaveLength(1); - expect(mockHttpClient.requests[0].url).toBe( - 'https://test.xmatters.com/api/xm/1/groups/new-group', - ); - expect(mockHttpClient.requests[0].method).toBe('POST'); mockHttpClient.verifyAllRequestsMade(); }); @@ -170,17 +126,10 @@ Deno.test('ResourceClient', async (t) => { expectedRequest: { method: 'PUT', url: 'https://test.xmatters.com/api/xm/1/groups/123', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.BASIC_AUTH_HEADERS, body: { name: 'Updated Group' }, }, mockedResponse: { - status: 200, - headers: { 'content-type': 'application/json' }, body: { id: '123' }, }, }]); @@ -188,9 +137,6 @@ Deno.test('ResourceClient', async (t) => { path: '123', body: { name: 'Updated Group' }, }); - expect(mockHttpClient.requests).toHaveLength(1); - expect(mockHttpClient.requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/123'); - expect(mockHttpClient.requests[0].method).toBe('PUT'); mockHttpClient.verifyAllRequestsMade(); }); @@ -201,17 +147,10 @@ Deno.test('ResourceClient', async (t) => { expectedRequest: { method: 'PATCH', url: 'https://test.xmatters.com/api/xm/1/groups/123', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.BASIC_AUTH_HEADERS, body: { name: 'Patched Group' }, }, mockedResponse: { - status: 200, - headers: { 'content-type': 'application/json' }, body: { id: '123' }, }, }]); @@ -219,9 +158,6 @@ Deno.test('ResourceClient', async (t) => { path: '123', body: { name: 'Patched Group' }, }); - expect(mockHttpClient.requests).toHaveLength(1); - expect(mockHttpClient.requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/123'); - expect(mockHttpClient.requests[0].method).toBe('PATCH'); mockHttpClient.verifyAllRequestsMade(); }); @@ -232,21 +168,13 @@ Deno.test('ResourceClient', async (t) => { expectedRequest: { method: 'DELETE', url: 'https://test.xmatters.com/api/xm/1/groups/123', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.BASIC_AUTH_HEADERS, }, mockedResponse: { status: 204, }, }]); await client.delete({ path: '123' }); - expect(mockHttpClient.requests).toHaveLength(1); - expect(mockHttpClient.requests[0].url).toBe('https://test.xmatters.com/api/xm/1/groups/123'); - expect(mockHttpClient.requests[0].method).toBe('DELETE'); mockHttpClient.verifyAllRequestsMade(); }); }); @@ -259,24 +187,13 @@ Deno.test('ResourceClient', async (t) => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups/123/members/456', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.BASIC_AUTH_HEADERS, }, mockedResponse: { - status: 200, - headers: { 'content-type': 'application/json' }, body: { success: true }, }, }]); await client.get({ path: '123/members/456' }); - expect(mockHttpClient.requests).toHaveLength(1); - expect(mockHttpClient.requests[0].url).toBe( - 'https://test.xmatters.com/api/xm/1/groups/123/members/456', - ); mockHttpClient.verifyAllRequestsMade(); }); @@ -290,16 +207,11 @@ Deno.test('ResourceClient', async (t) => { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups/members?page=1&limit=10', headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + ...TestConstants.BASIC_AUTH_HEADERS, 'Custom-Header': 'test-value', }, }, mockedResponse: { - status: 200, - headers: { 'content-type': 'application/json' }, body: { success: true }, }, }]); @@ -308,12 +220,6 @@ Deno.test('ResourceClient', async (t) => { headers: testHeaders, query: testQuery, }); - expect(mockHttpClient.requests).toHaveLength(1); - // Check that custom headers are included - expect(mockHttpClient.requests[0].headers?.['Custom-Header']).toBe('test-value'); - expect(mockHttpClient.requests[0].url).toBe( - 'https://test.xmatters.com/api/xm/1/groups/members?page=1&limit=10', - ); mockHttpClient.verifyAllRequestsMade(); }); }); diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts index 3f019a7..54c9154 100644 --- a/src/core/test-utils.ts +++ b/src/core/test-utils.ts @@ -212,7 +212,7 @@ export const TestConstants = { 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', // base64 of testuser:testpass 'Content-Type': 'application/json', 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + 'User-Agent': 'xmas/0.0.1 (Deno)', // Should match version in deno.json } as const, /** Standard OAuth test configuration for creating RequestHandler instances */ @@ -228,6 +228,12 @@ export const TestConstants = { 'Authorization': 'Bearer test-access-token', 'Content-Type': 'application/json', 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + 'User-Agent': 'xmas/0.0.1 (Deno)', // Should match version in deno.json + } as const, + + TOKEN_REFRESH_REQUEST_HEADERS: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'User-Agent': 'xmas/0.0.1 (Deno)', // Should match version in deno.json } as const, } as const; diff --git a/src/index.test.ts b/src/index.test.ts index 502f6ac..eaaeab6 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,39 +1,32 @@ import { expect } from 'std/expect/mod.ts'; -import { MockHttpClient, MockLogger, withFakeTime } from 'core/test-utils.ts'; +import { MockHttpClient, MockLogger, TestConstants, withFakeTime } from 'core/test-utils.ts'; import { XmApi, XmApiError } from './index.ts'; const mockHttpClient = new MockHttpClient(); const mockLogger = new MockLogger(); +// A basicAuth instance that can be used in many integration test cases +const runOfTheMillBasicAuthInstance = new XmApi({ + ...TestConstants.BASIC_CONFIG, + httpClient: mockHttpClient, + logger: mockLogger, +}); + Deno.test('XmApi Integration Tests', async (t) => { await t.step('Authentication', async (t) => { await t.step('Basic Auth Integration', async () => { - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, - }); // Test a simple GET request mockHttpClient.setReqRes([{ expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups?limit=10', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', // base64 of testuser:testpass - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.BASIC_AUTH_HEADERS, }, mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, body: { count: 0, total: 0, data: [] }, }, }]); - const response = await api.groups.get({ query: { limit: 10 } }); + const response = await runOfTheMillBasicAuthInstance.groups.get({ query: { limit: 10 } }); expect(response.status).toBe(200); expect(response.body.count).toBe(0); mockHttpClient.verifyAllRequestsMade(); @@ -41,10 +34,7 @@ Deno.test('XmApi Integration Tests', async (t) => { await t.step('OAuth Token Integration', async () => { const api = new XmApi({ - hostname: 'test.xmatters.com', - accessToken: 'test-access-token', - refreshToken: 'test-refresh-token', - clientId: 'test-client-id', + ...TestConstants.OAUTH_CONFIG, httpClient: mockHttpClient, logger: mockLogger, }); @@ -53,16 +43,9 @@ Deno.test('XmApi Integration Tests', async (t) => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Bearer test-access-token', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.OAUTH_HEADERS, }, mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, body: { count: 1, total: 1, data: [{ id: '123', targetName: 'Test Group' }] }, }, }]); @@ -96,15 +79,12 @@ Deno.test('XmApi Integration Tests', async (t) => { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', headers: { + ...TestConstants.OAUTH_HEADERS, 'Authorization': 'Bearer expired-token', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, mockedResponse: { status: 401, - headers: { 'Content-Type': 'application/json' }, body: { error: 'Unauthorized' }, }, }, @@ -113,17 +93,11 @@ Deno.test('XmApi Integration Tests', async (t) => { expectedRequest: { method: 'POST', url: 'https://test.xmatters.com/api/xm/1/oauth2/token', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.TOKEN_REFRESH_REQUEST_HEADERS, body: 'grant_type=refresh_token&refresh_token=valid-refresh-token&client_id=test-client-id', }, mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, body: { access_token: 'new-access-token', refresh_token: 'new-refresh-token', @@ -138,15 +112,11 @@ Deno.test('XmApi Integration Tests', async (t) => { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', headers: { + ...TestConstants.OAUTH_HEADERS, 'Authorization': 'Bearer new-access-token', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, body: { count: 1, total: 1, data: [{ id: '123', targetName: 'Test Group' }] }, }, }, @@ -192,15 +162,12 @@ Deno.test('XmApi Integration Tests', async (t) => { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', headers: { + ...TestConstants.OAUTH_HEADERS, 'Authorization': 'Bearer expired-token', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, mockedResponse: { status: 401, - headers: { 'Content-Type': 'application/json' }, body: { error: 'Unauthorized' }, }, }, @@ -209,17 +176,11 @@ Deno.test('XmApi Integration Tests', async (t) => { expectedRequest: { method: 'POST', url: 'https://test.xmatters.com/api/xm/1/oauth2/token', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.TOKEN_REFRESH_REQUEST_HEADERS, body: 'grant_type=refresh_token&refresh_token=valid-refresh-token&client_id=test-client-id', }, mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, body: { access_token: 'new-access-token', refresh_token: 'new-refresh-token', @@ -234,15 +195,11 @@ Deno.test('XmApi Integration Tests', async (t) => { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', headers: { + ...TestConstants.OAUTH_HEADERS, 'Authorization': 'Bearer new-access-token', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, body: { count: 0, total: 0, data: [] }, }, }, @@ -270,10 +227,8 @@ Deno.test('XmApi Integration Tests', async (t) => { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', headers: { + ...TestConstants.OAUTH_HEADERS, 'Authorization': 'Bearer expired-token', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', }, }, mockedResponse: { @@ -287,17 +242,12 @@ Deno.test('XmApi Integration Tests', async (t) => { expectedRequest: { method: 'POST', url: 'https://test.xmatters.com/api/xm/1/oauth2/token', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.TOKEN_REFRESH_REQUEST_HEADERS, body: 'grant_type=refresh_token&refresh_token=invalid-refresh-token&client_id=test-client-id', }, mockedResponse: { status: 401, - headers: { 'Content-Type': 'application/json' }, // Real error structure from xMatters API (verified via sandbox testing) body: { code: 401, message: 'Invalid refresh token', reason: 'Unauthorized' }, }, @@ -323,9 +273,7 @@ Deno.test('XmApi Integration Tests', async (t) => { await t.step('OAuth Token Acquisition', async () => { let tokenRefreshCalled = false; const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', + ...TestConstants.BASIC_CONFIG, httpClient: mockHttpClient, logger: mockLogger, onTokenRefresh: (accessToken, refreshToken) => { @@ -339,16 +287,10 @@ Deno.test('XmApi Integration Tests', async (t) => { expectedRequest: { method: 'POST', url: 'https://test.xmatters.com/api/xm/1/oauth2/token', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.TOKEN_REFRESH_REQUEST_HEADERS, body: 'grant_type=password&client_id=test-client&username=testuser&password=testpass', }, mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, body: { access_token: 'obtained-access-token', refresh_token: 'obtained-refresh-token', @@ -361,13 +303,6 @@ Deno.test('XmApi Integration Tests', async (t) => { expect(response.status).toBe(200); expect(response.body.access_token).toBe('obtained-access-token'); expect(tokenRefreshCalled).toBe(true); - // Validate that the request body contains the expected parameters - const request = mockHttpClient.requests[0]; - const bodyString = request.body as string; - expect(bodyString).toContain('grant_type=password'); - expect(bodyString).toContain('username=testuser'); - expect(bodyString).toContain('password=testpass'); - expect(bodyString).toContain('client_id=test-client'); mockHttpClient.verifyAllRequestsMade(); }); }); @@ -375,9 +310,7 @@ Deno.test('XmApi Integration Tests', async (t) => { await t.step('HTTP Client & Request Handling', async (t) => { await t.step('Custom Headers Integration', async () => { const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', + ...TestConstants.BASIC_CONFIG, httpClient: mockHttpClient, logger: mockLogger, defaultHeaders: { @@ -390,17 +323,12 @@ Deno.test('XmApi Integration Tests', async (t) => { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', + ...TestConstants.BASIC_AUTH_HEADERS, 'X-Custom-Header': 'custom-value', 'X-Client-Version': '1.0.0', }, }, mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, body: { count: 0, total: 0, data: [] }, }, }]); @@ -410,31 +338,17 @@ Deno.test('XmApi Integration Tests', async (t) => { }); await t.step('User-Agent Header', async () => { - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, - }); mockHttpClient.setReqRes([{ expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', // Should match version in deno.json - }, + headers: TestConstants.BASIC_AUTH_HEADERS, }, mockedResponse: { - status: 200, - headers: { 'Content-Type': 'application/json' }, body: { count: 0, total: 0, data: [] }, }, }]); - const response = await api.groups.get(); + const response = await runOfTheMillBasicAuthInstance.groups.get(); expect(response.status).toBe(200); mockHttpClient.verifyAllRequestsMade(); }); @@ -460,9 +374,7 @@ Deno.test('XmApi Integration Tests', async (t) => { { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, ]); const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', + ...TestConstants.BASIC_CONFIG, httpClient: mockHttpClient, logger: mockLogger, maxRetries: 2, @@ -473,12 +385,7 @@ Deno.test('XmApi Integration Tests', async (t) => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.BASIC_AUTH_HEADERS, }, mockedResponse: { status: 429, @@ -491,12 +398,7 @@ Deno.test('XmApi Integration Tests', async (t) => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.BASIC_AUTH_HEADERS, }, mockedResponse: { status: 429, @@ -509,12 +411,7 @@ Deno.test('XmApi Integration Tests', async (t) => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.BASIC_AUTH_HEADERS, }, mockedResponse: { status: 200, @@ -559,9 +456,7 @@ Deno.test('XmApi Integration Tests', async (t) => { { level: 'debug', message: /^<-- 200 \(\d+ms\)$/ }, ]); const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', + ...TestConstants.BASIC_CONFIG, httpClient: mockHttpClient, logger: mockLogger, maxRetries: 1, @@ -572,16 +467,10 @@ Deno.test('XmApi Integration Tests', async (t) => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.BASIC_AUTH_HEADERS, }, mockedResponse: { status: 500, - headers: { 'Content-Type': 'application/json' }, body: { error: 'Internal Server Error' }, }, }, @@ -590,12 +479,7 @@ Deno.test('XmApi Integration Tests', async (t) => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.BASIC_AUTH_HEADERS, }, mockedResponse: { status: 200, @@ -629,9 +513,7 @@ Deno.test('XmApi Integration Tests', async (t) => { await t.step('Max Retries Exceeded', async () => { return await withFakeTime(async (fakeTime) => { const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', + ...TestConstants.BASIC_CONFIG, httpClient: mockHttpClient, logger: mockLogger, maxRetries: 1, @@ -642,16 +524,10 @@ Deno.test('XmApi Integration Tests', async (t) => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.BASIC_AUTH_HEADERS, }, mockedResponse: { status: 500, - headers: { 'Content-Type': 'application/json' }, body: { reason: 'Internal Server Error' }, }, }, @@ -660,16 +536,10 @@ Deno.test('XmApi Integration Tests', async (t) => { expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.BASIC_AUTH_HEADERS, }, mockedResponse: { status: 500, - headers: { 'Content-Type': 'application/json' }, body: { reason: 'Internal Server Error' }, }, }, @@ -703,23 +573,11 @@ Deno.test('XmApi Integration Tests', async (t) => { await t.step('Error Handling', async (t) => { await t.step('HTTP Error Response Structure', async () => { - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, - }); mockHttpClient.setReqRes([{ expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups/nonexistent', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.BASIC_AUTH_HEADERS, }, mockedResponse: { status: 404, @@ -731,7 +589,7 @@ Deno.test('XmApi Integration Tests', async (t) => { // This tests a different scenario than network errors - here the server successfully // responds but with an error status code, so XmApiError should contain response details try { - await api.groups.getByIdentifier('nonexistent'); + await runOfTheMillBasicAuthInstance.groups.getByIdentifier('nonexistent'); } catch (error) { const apiError = error as XmApiError; expect(apiError).toBeInstanceOf(XmApiError); @@ -748,30 +606,18 @@ Deno.test('XmApi Integration Tests', async (t) => { }); await t.step('Network Error Handling', async () => { - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, - }); // Use mockedError to simulate network connection failure mockHttpClient.setReqRes([{ expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.BASIC_AUTH_HEADERS, }, mockedError: new Error('Network connection failed'), }]); // MockHttpClient with mockedError will always reject, so we can test error handling directly try { - await api.groups.get(); + await runOfTheMillBasicAuthInstance.groups.get(); } catch (error) { const apiError = error as XmApiError; expect(apiError).toBeInstanceOf(XmApiError); @@ -784,32 +630,19 @@ Deno.test('XmApi Integration Tests', async (t) => { }); await t.step('Non-JSON Response Body Handling', async () => { - const api = new XmApi({ - hostname: 'test.xmatters.com', - username: 'testuser', - password: 'testpass', - httpClient: mockHttpClient, - logger: mockLogger, - }); mockHttpClient.setReqRes([{ expectedRequest: { method: 'GET', url: 'https://test.xmatters.com/api/xm/1/groups/invalid', - headers: { - 'Authorization': 'Basic dGVzdHVzZXI6dGVzdHBhc3M=', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'xmas/0.0.1 (Deno)', - }, + headers: TestConstants.BASIC_AUTH_HEADERS, }, mockedResponse: { status: 400, - headers: { 'Content-Type': 'text/plain' }, body: 'Invalid request format', }, }]); try { - await api.groups.getByIdentifier('invalid'); + await runOfTheMillBasicAuthInstance.groups.getByIdentifier('invalid'); } catch (error) { const apiError = error as XmApiError; expect(apiError).toBeInstanceOf(XmApiError); From c7efc7508f9610a7a3fbceeebf5ff76b15322fc2 Mon Sep 17 00:00:00 2001 From: johan Date: Sat, 5 Jul 2025 10:58:21 -0700 Subject: [PATCH 092/101] Add unit test for oauth endpoint --- src/core/test-utils.ts | 2 +- src/endpoints/oauth/index.test.ts | 329 ++++++++++++++++++++++++++++++ src/index.test.ts | 8 +- 3 files changed, 334 insertions(+), 5 deletions(-) diff --git a/src/core/test-utils.ts b/src/core/test-utils.ts index 54c9154..5da9a2e 100644 --- a/src/core/test-utils.ts +++ b/src/core/test-utils.ts @@ -231,7 +231,7 @@ export const TestConstants = { 'User-Agent': 'xmas/0.0.1 (Deno)', // Should match version in deno.json } as const, - TOKEN_REFRESH_REQUEST_HEADERS: { + TOKEN_REQUEST_HEADERS: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', 'User-Agent': 'xmas/0.0.1 (Deno)', // Should match version in deno.json diff --git a/src/endpoints/oauth/index.test.ts b/src/endpoints/oauth/index.test.ts index e69de29..d7b7b98 100644 --- a/src/endpoints/oauth/index.test.ts +++ b/src/endpoints/oauth/index.test.ts @@ -0,0 +1,329 @@ +import { expect } from 'std/expect/mod.ts'; +import { OAuthEndpoint } from './index.ts'; +import { MockHttpClient, MockLogger, TestConstants } from 'core/test-utils.ts'; +import { RequestHandler } from 'core/request-handler.ts'; +import { XmApiError } from 'core/errors.ts'; +import { AuthType } from 'types/mutable-auth-state.ts'; + +// Shared test infrastructure - MockHttpClient auto-resets between tests +const mockHttpClient = new MockHttpClient(); +const mockLogger = new MockLogger(); + +// Helper function to create fresh RequestHandlers for each test +function createBasicAuthRequestHandler() { + return new RequestHandler({ + httpClient: mockHttpClient, + logger: mockLogger, + ...TestConstants.BASIC_CONFIG, + }); +} + +function createAuthCodeRequestHandler() { + return new RequestHandler({ + httpClient: mockHttpClient, + logger: mockLogger, + hostname: 'https://test.xmatters.com', + authorizationCode: 'test-auth-code', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }); +} + +function createOAuthRequestHandler() { + return new RequestHandler({ + httpClient: mockHttpClient, + logger: mockLogger, + ...TestConstants.OAUTH_CONFIG, + }); +} + +const mockOAuth2TokenResponse = { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_in: 3600, + token_type: 'Bearer', +}; + +Deno.test('OAuthEndpoint', async (t) => { + await t.step('obtainTokens() - From Basic Auth', async (t) => { + await t.step('performs password grant flow with clientId', async () => { + const oauth = new OAuthEndpoint(createBasicAuthRequestHandler()); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', + headers: TestConstants.TOKEN_REQUEST_HEADERS, + body: 'grant_type=password&client_id=test-client-id&username=testuser&password=testpass', + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockOAuth2TokenResponse, + }, + }]); + await oauth.obtainTokens({ clientId: 'test-client-id' }); + }); + + await t.step('performs password grant flow with clientId and clientSecret', async () => { + const oauth = new OAuthEndpoint(createBasicAuthRequestHandler()); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', + headers: TestConstants.TOKEN_REQUEST_HEADERS, + body: + 'grant_type=password&client_id=test-client-id&username=testuser&password=testpass&client_secret=test-client-secret', + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockOAuth2TokenResponse, + }, + }]); + await oauth.obtainTokens({ + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }); + }); + + await t.step('throws error when clientId is not provided (no request attempted)', async () => { + const oauth = new OAuthEndpoint(createBasicAuthRequestHandler()); + try { + await oauth.obtainTokens(); + // If no error is thrown, this is a failure + throw new Error('Expected error to be thrown'); + } catch (error) { + if (error instanceof XmApiError) { + expect(error.message).toBe( + 'Client ID discovery not yet implemented - please provide explicit clientId', + ); + } else { + throw error; + } + } + }); + }); + + await t.step('obtainTokens() - From Auth Code', async (t) => { + await t.step('performs authorization code flow with client secret from config', async () => { + const oauth = new OAuthEndpoint(createAuthCodeRequestHandler()); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', + headers: TestConstants.TOKEN_REQUEST_HEADERS, + body: + 'grant_type=authorization_code&authorization_code=test-auth-code&client_secret=test-client-secret', + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockOAuth2TokenResponse, + }, + }]); + await oauth.obtainTokens(); + }); + + await t.step('performs authorization code flow with client secret from params', async () => { + const oauth = new OAuthEndpoint(createAuthCodeRequestHandler()); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', + headers: TestConstants.TOKEN_REQUEST_HEADERS, + body: + 'grant_type=authorization_code&authorization_code=test-auth-code&client_secret=override-client-secret', + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockOAuth2TokenResponse, + }, + }]); + await oauth.obtainTokens({ clientSecret: 'override-client-secret' }); + }); + + await t.step('performs authorization code flow without client secret', async () => { + // Create auth code handler without client secret + const authCodeRequestHandlerNoSecret = new RequestHandler({ + httpClient: mockHttpClient, + logger: mockLogger, + hostname: 'https://test.xmatters.com', + authorizationCode: 'test-auth-code', + clientId: 'test-client-id', + }); + const oauthNoSecret = new OAuthEndpoint(authCodeRequestHandlerNoSecret); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', + headers: TestConstants.TOKEN_REQUEST_HEADERS, + body: 'grant_type=authorization_code&authorization_code=test-auth-code', + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockOAuth2TokenResponse, + }, + }]); + await oauthNoSecret.obtainTokens(); + }); + }); + + await t.step('obtainTokens() - From OAuth', async (t) => { + await t.step('throws error when already have OAuth tokens (No request attempted)', async () => { + const oauth = new OAuthEndpoint(createOAuthRequestHandler()); + try { + await oauth.obtainTokens(); + // If no error is thrown, this is a failure + throw new Error('Expected error to be thrown'); + } catch (error) { + if (error instanceof XmApiError) { + expect(error.message).toBe('Already have OAuth tokens - no need to call obtainTokens()'); + } else { + throw error; + } + } + }); + }); + + await t.step('Token Request Handling', async (t) => { + await t.step('handles successful token response', async () => { + const oauth = new OAuthEndpoint(createBasicAuthRequestHandler()); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', + headers: TestConstants.TOKEN_REQUEST_HEADERS, + body: 'grant_type=password&client_id=test-client-id&username=testuser&password=testpass', + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockOAuth2TokenResponse, + }, + }]); + const response = await oauth.obtainTokens({ clientId: 'test-client-id' }); + expect(response.status).toBe(200); + expect(response.body).toEqual(mockOAuth2TokenResponse); + }); + + await t.step('handles HTTP error responses', async () => { + const oauth = new OAuthEndpoint(createBasicAuthRequestHandler()); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', + headers: TestConstants.TOKEN_REQUEST_HEADERS, + body: 'grant_type=password&client_id=invalid-client&username=testuser&password=testpass', + }, + mockedResponse: { + status: 400, + headers: { 'content-type': 'application/json' }, + body: { + error: 'invalid_client', + error_description: 'Client authentication failed', + }, + }, + }]); + try { + await oauth.obtainTokens({ clientId: 'invalid-client' }); + // If no error is thrown, this is a failure + throw new Error('Expected XmApiError to be thrown'); + } catch (error) { + if (error instanceof XmApiError) { + expect(error.response?.status).toBe(400); + expect((error.response?.body as unknown as { error: string })?.error).toBe( + 'invalid_client', + ); + } else { + throw error; + } + } + }); + + await t.step('handles network errors', async () => { + const oauth = new OAuthEndpoint(createBasicAuthRequestHandler()); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', + headers: TestConstants.TOKEN_REQUEST_HEADERS, + body: 'grant_type=password&client_id=test-client-id&username=testuser&password=testpass', + }, + mockedError: new Error('Network connection failed'), + }]); + try { + await oauth.obtainTokens({ clientId: 'test-client-id' }); + // If no error is thrown, this is a failure + throw new Error('Expected XmApiError to be thrown'); + } catch (error) { + if (error instanceof XmApiError) { + expect(error.message).toBe('Request failed'); + expect((error.cause as Error)?.message).toBe('Network connection failed'); + } else { + throw error; + } + } + }); + }); + + await t.step('Form Data Building', async (t) => { + await t.step('properly encodes special characters in form data', async () => { + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', + headers: TestConstants.TOKEN_REQUEST_HEADERS, + body: + 'grant_type=password&client_id=test%40client&username=user%2Bname&password=pass%26word', + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockOAuth2TokenResponse, + }, + }]); + // Create a request handler with special characters + const specialCharsRequestHandler = new RequestHandler({ + httpClient: mockHttpClient, + logger: mockLogger, + hostname: 'https://test.xmatters.com', + username: 'user+name', + password: 'pass&word', + }); + const specialCharsOauth = new OAuthEndpoint(specialCharsRequestHandler); + await specialCharsOauth.obtainTokens({ clientId: 'test@client' }); + }); + }); + + await t.step('Integration with RequestHandler', async (t) => { + await t.step('calls handleNewOAuthTokens after successful response', async () => { + const requestHandler = createBasicAuthRequestHandler(); + const oauth = new OAuthEndpoint(requestHandler); + mockHttpClient.setReqRes([{ + expectedRequest: { + method: 'POST', + url: 'https://test.xmatters.com/api/xm/1/oauth2/token', + headers: TestConstants.TOKEN_REQUEST_HEADERS, + body: 'grant_type=password&client_id=test-client-id&username=testuser&password=testpass', + }, + mockedResponse: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: mockOAuth2TokenResponse, + }, + }]); + // This will internally call handleNewOAuthTokens which transitions auth state + await oauth.obtainTokens({ clientId: 'test-client-id' }); + // Verify that the auth state was properly updated by checking the current state + const currentState = requestHandler.getCurrentAuthState(); + expect(currentState.type).toBe(AuthType.OAUTH); + if (currentState.type === AuthType.OAUTH) { + expect(currentState.accessToken).toBe(mockOAuth2TokenResponse.access_token); + expect(currentState.refreshToken).toBe(mockOAuth2TokenResponse.refresh_token); + expect(currentState.clientId).toBe('test-client-id'); + } + }); + }); +}); diff --git a/src/index.test.ts b/src/index.test.ts index eaaeab6..ca2f10d 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -93,7 +93,7 @@ Deno.test('XmApi Integration Tests', async (t) => { expectedRequest: { method: 'POST', url: 'https://test.xmatters.com/api/xm/1/oauth2/token', - headers: TestConstants.TOKEN_REFRESH_REQUEST_HEADERS, + headers: TestConstants.TOKEN_REQUEST_HEADERS, body: 'grant_type=refresh_token&refresh_token=valid-refresh-token&client_id=test-client-id', }, @@ -176,7 +176,7 @@ Deno.test('XmApi Integration Tests', async (t) => { expectedRequest: { method: 'POST', url: 'https://test.xmatters.com/api/xm/1/oauth2/token', - headers: TestConstants.TOKEN_REFRESH_REQUEST_HEADERS, + headers: TestConstants.TOKEN_REQUEST_HEADERS, body: 'grant_type=refresh_token&refresh_token=valid-refresh-token&client_id=test-client-id', }, @@ -242,7 +242,7 @@ Deno.test('XmApi Integration Tests', async (t) => { expectedRequest: { method: 'POST', url: 'https://test.xmatters.com/api/xm/1/oauth2/token', - headers: TestConstants.TOKEN_REFRESH_REQUEST_HEADERS, + headers: TestConstants.TOKEN_REQUEST_HEADERS, body: 'grant_type=refresh_token&refresh_token=invalid-refresh-token&client_id=test-client-id', }, @@ -287,7 +287,7 @@ Deno.test('XmApi Integration Tests', async (t) => { expectedRequest: { method: 'POST', url: 'https://test.xmatters.com/api/xm/1/oauth2/token', - headers: TestConstants.TOKEN_REFRESH_REQUEST_HEADERS, + headers: TestConstants.TOKEN_REQUEST_HEADERS, body: 'grant_type=password&client_id=test-client&username=testuser&password=testpass', }, mockedResponse: { From e06441a81b97d059243117e43ef2c110c2bb3ee3 Mon Sep 17 00:00:00 2001 From: johan Date: Thu, 10 Jul 2025 20:43:05 -0700 Subject: [PATCH 093/101] Minor legal things --- LICENSE | 2 +- README.md | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/LICENSE b/LICENSE index 21ad091..cafd5ff 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 xMatters +Copyright (c) 2025 J. Friedrich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index d47c173..6dfdaed 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,8 @@ await xmApi.oauth.obtainTokens({ const groups = await xmApi.groups.get(); ``` -The library will automatically start using the OAuth tokens and purge the username & password you -instantiated it with. +> **Note:** 🔐 The library will automatically start using the OAuth tokens +> and purge the username & password you instantiated it with. ## Dependency injection @@ -124,7 +124,7 @@ For projects that already use axios, you can use the provided adapter function: ```ts import axios from 'axios'; -import { createAxiosAdapter, XmApi } from '@johanfive/xmas'; +import { axiosAdapter, XmApi } from '@johanfive/xmas'; // Create your axios instance with whatever config you need const axiosInstance = axios.create({ @@ -139,14 +139,14 @@ const config = { hostname: 'https://yourOrg.xmatters.com', username: 'authingUserName', password: 'authingUserPassword', - httpClient: createAxiosAdapter(axiosInstance), + httpClient: axiosAdapter(axiosInstance), }; const xmApi = new XmApi(config); ``` -> **Note:** Only use this if your project already uses axios. Otherwise, the default HTTP client -> (fetch) works great with zero dependencies. +> **Note:** ⚠️ Only use this if your project already uses axios. +> Otherwise, the default HTTP client (fetch) works great with zero dependencies. #### Custom Implementation @@ -179,11 +179,11 @@ const config = { password: 'authingUserPassword', httpClient: myHttpClient, }; -// Note: While your HTTP client should not throw on HTTP error status codes (4xx, 5xx), +// Note: ⚠️ While your HTTP client should not throw on HTTP error status codes (4xx, 5xx), // the xmApi SDK itself WILL throw XmApiError instances when the xMatters API returns error responses. // The library philosophy is that generic HTTP clients should stay simple and predictable, -// while the SDKs leverage their deep knowledge of an API to provide clean +// while SDKs leverage their deep knowledge of an API to provide clean // exception-based error handling in your application code. // The HTTP client simply returning the response when it has one, regardless of the status code, @@ -353,10 +353,10 @@ interface XmApiConfig { // Optional dependencies httpClient?: HttpClient; // Custom HTTP implementation logger?: Logger; // Custom logging implementation + onTokenRefresh?: TokenRefreshCallback; // Handle token refresh events // Optional settings defaultHeaders?: Headers; // Additional headers for all requests maxRetries?: number; // Maximum retry attempts - onTokenRefresh?: TokenRefreshCallback; // Handle token refresh events } ``` From f2f6b9fe94ab5746c198c218c05a815f4ae6bf54 Mon Sep 17 00:00:00 2001 From: johan Date: Thu, 10 Jul 2025 23:30:54 -0700 Subject: [PATCH 094/101] CI/CD experiment --- .github/workflows/publishToJsr.yml | 25 +++++++++++++++++++++++++ .github/workflows/validation.yml | 22 ++++++++++++++++++++++ .vscode/settings.json | 4 ++++ README.md | 8 ++++---- 4 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/publishToJsr.yml create mode 100644 .github/workflows/validation.yml diff --git a/.github/workflows/publishToJsr.yml b/.github/workflows/publishToJsr.yml new file mode 100644 index 0000000..b7b69ca --- /dev/null +++ b/.github/workflows/publishToJsr.yml @@ -0,0 +1,25 @@ +name: Publish to JSR + +on: + push: + tags: + - 'v*' # Triggers on version tags like v0.1.0 + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + + - name: Authenticate with JSR + run: deno login --token ${{ secrets.JSR_TOKEN }} + + - name: Publish to JSR + run: deno publish --dry-run diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml new file mode 100644 index 0000000..f2d5666 --- /dev/null +++ b/.github/workflows/validation.yml @@ -0,0 +1,22 @@ +name: Build + +on: push + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x # Run with latest stable Deno. + + - run: deno fmt --check + + - run: deno lint + + - run: deno test --coverage=cov/ + + # This generates a report from the collected coverage in `deno test --coverage`. It is + # stored as a .lcov file which integrates well with services such as Codecov, Coveralls and Travis CI. + - run: deno coverage --lcov cov/ > cov.lcov diff --git a/.vscode/settings.json b/.vscode/settings.json index 7a6baba..b253459 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,6 +11,10 @@ "editor.defaultFormatter": "denoland.vscode-deno", "editor.formatOnSave": true }, + "[yaml]": { + "editor.defaultFormatter": "denoland.vscode-deno", + "editor.formatOnSave": true + }, "deno.enable": true, "deno.lint": true } diff --git a/README.md b/README.md index 6dfdaed..9874988 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,8 @@ await xmApi.oauth.obtainTokens({ const groups = await xmApi.groups.get(); ``` -> **Note:** 🔐 The library will automatically start using the OAuth tokens -> and purge the username & password you instantiated it with. +> **Note:** 🔐 The library will automatically start using the OAuth tokens and purge the username & +> password you instantiated it with. ## Dependency injection @@ -145,8 +145,8 @@ const config = { const xmApi = new XmApi(config); ``` -> **Note:** ⚠️ Only use this if your project already uses axios. -> Otherwise, the default HTTP client (fetch) works great with zero dependencies. +> **Note:** ⚠️ Only use this if your project already uses axios. Otherwise, the default HTTP client +> (fetch) works great with zero dependencies. #### Custom Implementation From 80bb02ac06f6001ea1bf5d7d75ecbd8a6983d2de Mon Sep 17 00:00:00 2001 From: johan Date: Fri, 11 Jul 2025 09:23:15 -0700 Subject: [PATCH 095/101] CI/CD experiment --- deno.json | 1 + src/endpoints/integrations/index.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/deno.json b/deno.json index b33e191..871e2fd 100644 --- a/deno.json +++ b/deno.json @@ -2,6 +2,7 @@ "name": "@johanfive/xmas", "version": "0.0.1", "exports": "./src/index.ts", + "license": "MIT", "imports": { "std/": "https://deno.land/std@0.224.0/", "types/": "./src/core/types/", diff --git a/src/endpoints/integrations/index.ts b/src/endpoints/integrations/index.ts index a6e9aff..660c771 100644 --- a/src/endpoints/integrations/index.ts +++ b/src/endpoints/integrations/index.ts @@ -22,7 +22,7 @@ export class IntegrationsEndpoint { * * @param url The URL of the integration trigger endpoint * @param payload The payload to send to the integration - * @returns The HTTP response containing a paginated list of integrations + * @returns The HTTP response containing a request ID * @throws {XmApiError} If the request fails */ trigger( From 141530e5074f166b4695e4ae54075e3469a1cbf7 Mon Sep 17 00:00:00 2001 From: johan Date: Fri, 11 Jul 2025 09:30:31 -0700 Subject: [PATCH 096/101] CI/CD experiment --- .github/workflows/publishToJsr.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/publishToJsr.yml b/.github/workflows/publishToJsr.yml index b7b69ca..550e633 100644 --- a/.github/workflows/publishToJsr.yml +++ b/.github/workflows/publishToJsr.yml @@ -18,8 +18,5 @@ jobs: with: deno-version: v2.x - - name: Authenticate with JSR - run: deno login --token ${{ secrets.JSR_TOKEN }} - - name: Publish to JSR run: deno publish --dry-run From 9df919ca262fa9238fc6af67a6099aca9063a894 Mon Sep 17 00:00:00 2001 From: johan Date: Fri, 11 Jul 2025 09:45:58 -0700 Subject: [PATCH 097/101] CI/CD experiment --- .github/workflows/validation.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml index f2d5666..e5ed7b2 100644 --- a/.github/workflows/validation.yml +++ b/.github/workflows/validation.yml @@ -1,6 +1,9 @@ name: Build -on: push +on: + push: + branches: + - '**' jobs: build: From ce52c0458da29534c61dbdaf0a223b5fd741f2f1 Mon Sep 17 00:00:00 2001 From: johan Date: Fri, 11 Jul 2025 09:54:42 -0700 Subject: [PATCH 098/101] Fix accident in the readme --- README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/README.md b/README.md index 9874988..9df955e 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,7 @@ `xmas` for short 🎄 -A TypeScript/JavaScript library for interacting with the xMatt// xmApi will now automatically use -OAuth tokens for all subsequent requests const groups = await xmApi.groups.get(); - -The library will automatically start using the OAuth tokens and purge the username & password from -memory for security.PI. +A TypeScript/JavaScript library for interacting with the xMatters API (xmApi). - 🎄 **Zero dependencies** - Uses only native fetch API - 🔒 **Multiple auth methods** - Basic auth, OAuth, and authorization code flow From 158783946937305c905119639fdfe818628019e5 Mon Sep 17 00:00:00 2001 From: johan Date: Fri, 11 Jul 2025 09:59:25 -0700 Subject: [PATCH 099/101] Extra auth info in readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 9df955e..ded158b 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ groupsResponse.body.data.forEach((group) => { }); ``` +> Note: xMatters also offers the ability to authenticate via `API key`. See +> [documentation here](https://help.xmatters.com/ondemand/user/apikeys.htm) and start using your API +> key as username and its associated secret as password. + ## OAuth Configuration If you already have OAuth tokens: From f89d407d54d63012a71a37dc1589fa5b7cc4e448 Mon Sep 17 00:00:00 2001 From: johan Date: Fri, 11 Jul 2025 10:02:05 -0700 Subject: [PATCH 100/101] CI/CD no more dry run --- .github/workflows/publishToJsr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publishToJsr.yml b/.github/workflows/publishToJsr.yml index 550e633..27b4ef2 100644 --- a/.github/workflows/publishToJsr.yml +++ b/.github/workflows/publishToJsr.yml @@ -19,4 +19,4 @@ jobs: deno-version: v2.x - name: Publish to JSR - run: deno publish --dry-run + run: deno publish From 2058f7216ef6d24b190c731a2b8b8803da75160e Mon Sep 17 00:00:00 2001 From: johan Date: Fri, 11 Jul 2025 10:33:29 -0700 Subject: [PATCH 101/101] CI/CD auth publish to JSR --- .github/workflows/publishToJsr.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/publishToJsr.yml b/.github/workflows/publishToJsr.yml index 27b4ef2..4563b1d 100644 --- a/.github/workflows/publishToJsr.yml +++ b/.github/workflows/publishToJsr.yml @@ -8,6 +8,9 @@ on: jobs: publish: runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # For Deno to authenticate with JSR steps: - name: Checkout code