diff --git a/README.md b/README.md index c3f839a8..6c5e7a86 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ There are two ways to define workspaces: - Inline in root `vitest.config.mts` -- In root config with glob string and vitest.config.ts in each workspace/package. Example: +- In root config with glob string and vitest.config.mjs in each workspace/package. Example: ```ts import { defineProject, mergeConfig } from 'vitest/config'; import configShared from '../../vitest.config'; diff --git a/package-lock.json b/package-lock.json index 42fe857c..0659796b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -650,6 +650,63 @@ "node": ">=6.9.0" } }, + "node_modules/@bundled-es-modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==", + "license": "ISC", + "peer": true, + "dependencies": { + "cookie": "^0.7.2" + } + }, + "node_modules/@bundled-es-modules/statuses": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz", + "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==", + "license": "ISC", + "peer": true, + "dependencies": { + "statuses": "^2.0.1" + } + }, + "node_modules/@bundled-es-modules/tough-cookie": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz", + "integrity": "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==", + "license": "ISC", + "peer": true, + "dependencies": { + "@types/tough-cookie": "^4.0.5", + "tough-cookie": "^4.1.4" + } + }, + "node_modules/@bundled-es-modules/tough-cookie/node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@bundled-es-modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/@byndyusoft-ui/css-utilities": { "resolved": "styles/utilities", "link": true @@ -666,6 +723,10 @@ "resolved": "components/highlighter", "link": true }, + "node_modules/@byndyusoft-ui/http": { + "resolved": "services/http", + "link": true + }, "node_modules/@byndyusoft-ui/keyframes-css": { "resolved": "styles/keyframes-css", "link": true @@ -1335,6 +1396,173 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@inquirer/confirm": { + "version": "5.1.12", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.12.tgz", + "integrity": "sha512-dpq+ielV9/bqgXRUbNH//KsY6WEw9DrGPmipkpmgC1Y46cwuBTNx7PXFWTjc3MQ+urcc0QxoVHcMI0FW4Ok0hg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@inquirer/core": "^10.1.13", + "@inquirer/type": "^3.0.7" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.1.13", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.13.tgz", + "integrity": "sha512-1viSxebkYN2nJULlzCxES6G9/stgHSepZ9LqqfdIGPHj5OHhiBUXVS0a6R0bEC2A+VL4D9w6QB66ebCr6HGllA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@inquirer/figures": "^1.0.12", + "@inquirer/type": "^3.0.7", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/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==", + "license": "MIT", + "peer": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@inquirer/core/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==", + "license": "MIT", + "peer": true + }, + "node_modules/@inquirer/core/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==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/core/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==", + "license": "MIT", + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/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==", + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.12.tgz", + "integrity": "sha512-MJttijd8rMFcKJC8NYmprWr6hD3r9Gd9qUC0XwPNwoEPWSMVJwA2MlXxF+nhZZNMY+HXsWa+o7KY2emWYIn0jQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.7.tgz", + "integrity": "sha512-PfunHQcjwnju84L+ycmcMKB/pTPIngjUJvfnRhKY6FKPuYXlM4aQCb/nIdTFR6BEhMjFvngzvng/vBAJMZpLSA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "dev": true, @@ -1473,6 +1701,24 @@ "react": ">=16" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.38.7", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.38.7.tgz", + "integrity": "sha512-Jkb27iSn7JPdkqlTqKfhncFfnEZsIJVYxsFbUSWEkxdIPdsyngrhoDBk0/BGD2FQcRH99vlRrkHpNTyKqI+0/w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "dev": true, @@ -1513,6 +1759,31 @@ "node": ">=12.4.0" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "license": "MIT", + "peer": true + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "license": "MIT", + "peer": true + }, "node_modules/@parcel/watcher": { "version": "2.5.1", "dev": true, @@ -2670,6 +2941,13 @@ "classnames": "*" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT", + "peer": true + }, "node_modules/@types/doctrine": { "version": "0.0.9", "dev": true, @@ -2702,7 +2980,7 @@ }, "node_modules/@types/node": { "version": "20.5.1", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/normalize-package-data": { @@ -2748,6 +3026,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/statuses": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.5.tgz", + "integrity": "sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT", + "peer": true + }, "node_modules/@types/uuid": { "version": "9.0.8", "dev": true, @@ -3249,7 +3541,6 @@ }, "node_modules/ansi-regex": { "version": "5.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3257,7 +3548,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3490,6 +3780,12 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "dev": true, @@ -3512,6 +3808,31 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios-mock-adapter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-2.1.0.tgz", + "integrity": "sha512-AZUe4OjECGCNNssH8SOdtneiQELsqTsat3SQQCWLPjN436/H+L9AjWfV7bF+Zg/YL9cgbhrz5671hoh+Tbn98w==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "is-buffer": "^2.0.5" + }, + "peerDependencies": { + "axios": ">= 0.17.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "dev": true, @@ -3707,7 +4028,6 @@ }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3902,9 +4222,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">= 12" + } + }, "node_modules/cliui": { "version": "8.0.1", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -3917,12 +4246,10 @@ }, "node_modules/cliui/node_modules/emoji-regex": { "version": "8.0.0", - "dev": true, "license": "MIT" }, "node_modules/cliui/node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3930,7 +4257,6 @@ }, "node_modules/cliui/node_modules/string-width": { "version": "4.2.3", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -3943,7 +4269,6 @@ }, "node_modules/cliui/node_modules/wrap-ansi": { "version": "7.0.0", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -3959,7 +4284,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3970,7 +4294,6 @@ }, "node_modules/color-name": { "version": "1.1.4", - "dev": true, "license": "MIT" }, "node_modules/colord": { @@ -3983,6 +4306,18 @@ "dev": true, "license": "MIT" }, + "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==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "11.0.0", "dev": true, @@ -4054,6 +4389,16 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cosmiconfig": { "version": "8.3.6", "dev": true, @@ -4389,6 +4734,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "dev": true, @@ -4466,7 +4820,6 @@ }, "node_modules/dunder-proto": { "version": "1.0.1", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -4588,7 +4941,6 @@ }, "node_modules/es-define-property": { "version": "1.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4596,7 +4948,6 @@ }, "node_modules/es-errors": { "version": "1.3.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4654,7 +5005,6 @@ }, "node_modules/es-object-atoms": { "version": "1.1.1", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4665,7 +5015,6 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4756,7 +5105,6 @@ }, "node_modules/escalade": { "version": "3.2.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5579,7 +5927,6 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -5722,6 +6069,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "dev": true, @@ -5762,6 +6129,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs-extra": { "version": "11.3.0", "dev": true, @@ -5782,7 +6164,6 @@ }, "node_modules/function-bind": { "version": "1.1.2", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5830,7 +6211,6 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -5838,7 +6218,6 @@ }, "node_modules/get-intrinsic": { "version": "1.3.0", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5861,7 +6240,6 @@ }, "node_modules/get-proto": { "version": "1.0.1", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -6074,7 +6452,6 @@ }, "node_modules/gopd": { "version": "1.2.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6098,6 +6475,16 @@ "dev": true, "license": "MIT" }, + "node_modules/graphql": { + "version": "16.11.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", + "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/happy-dom": { "version": "17.5.6", "dev": true, @@ -6169,7 +6556,6 @@ }, "node_modules/has-symbols": { "version": "1.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6180,7 +6566,6 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -6194,7 +6579,6 @@ }, "node_modules/hasown": { "version": "2.0.2", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6203,6 +6587,13 @@ "node": ">= 0.4" } }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "license": "MIT", + "peer": true + }, "node_modules/hookified": { "version": "1.9.0", "dev": true, @@ -6511,6 +6902,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4" + } + }, "node_modules/is-builtin-module": { "version": "3.2.1", "dev": true, @@ -6702,6 +7117,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "license": "MIT", + "peer": true + }, "node_modules/is-number": { "version": "7.0.0", "dev": true, @@ -7600,7 +8022,6 @@ }, "node_modules/math-intrinsics": { "version": "1.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7699,6 +8120,27 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime-db": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.24.0.tgz", + "integrity": "sha512-0XGpuLCNPqkv3vYiRjh1w6h4RbIGWyCh8OnXejta9INkFX0M8ENYth8O0As8rSGDxzEO1PafhiaqQdtqhtA2lw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.12.tgz", + "integrity": "sha512-irQD8Ww11AaU8vbCRjMuaq4huvb2ITxVg/VDBrvf8keFtbWZ3zbGO0tvsCMbD7JlR8mOYw0WbAqi4sL8KGUd5w==", + "license": "MIT", + "dependencies": { + "mime-db": "~1.24.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "dev": true, @@ -7760,6 +8202,74 @@ "dev": true, "license": "MIT" }, + "node_modules/msw": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.8.7.tgz", + "integrity": "sha512-0TGfV4oQiKpa3pDsQBDf0xvFP+sRrqEOnh2n1JWpHVKHJHLv6ZmY1HCZpCi7uDiJTeIHJMBpmBiRmBJN+ETPSQ==", + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@bundled-es-modules/cookie": "^2.0.1", + "@bundled-es-modules/statuses": "^1.0.1", + "@bundled-es-modules/tough-cookie": "^0.1.6", + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.38.7", + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/until": "^2.1.0", + "@types/cookie": "^0.6.0", + "@types/statuses": "^2.0.4", + "graphql": "^16.8.1", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "strict-event-emitter": "^0.5.1", + "type-fest": "^4.26.1", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "license": "ISC", + "peer": true, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "dev": true, @@ -8045,6 +8555,13 @@ "node": ">= 0.8.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "license": "MIT", + "peer": true + }, "node_modules/own-keys": { "version": "1.0.1", "dev": true, @@ -8190,6 +8707,13 @@ "dev": true, "license": "ISC" }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "license": "MIT", + "peer": true + }, "node_modules/path-type": { "version": "4.0.0", "dev": true, @@ -8213,7 +8737,6 @@ }, "node_modules/picocolors": { "version": "1.1.1", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -8453,14 +8976,39 @@ "version": "16.13.1", "license": "MIT" }, + "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==", + "license": "MIT" + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "peer": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT", + "peer": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "dev": true, @@ -8813,7 +9361,6 @@ }, "node_modules/require-directory": { "version": "2.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8835,6 +9382,13 @@ "node": ">=0.10.5" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT", + "peer": true + }, "node_modules/resolve": { "version": "1.22.10", "dev": true, @@ -9388,6 +9942,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.9.0", "dev": true, @@ -9430,6 +9994,13 @@ } } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "license": "MIT", + "peer": true + }, "node_modules/string_decoder": { "version": "1.3.0", "dev": true, @@ -9622,7 +10193,6 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -10388,7 +10958,7 @@ }, "node_modules/typescript": { "version": "4.9.5", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -10503,6 +11073,17 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util": { "version": "0.12.5", "dev": true, @@ -11115,7 +11696,6 @@ }, "node_modules/y18n": { "version": "5.0.8", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -11141,7 +11721,6 @@ }, "node_modules/yargs": { "version": "17.7.2", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -11166,12 +11745,10 @@ }, "node_modules/yargs/node_modules/emoji-regex": { "version": "8.0.0", - "dev": true, "license": "MIT" }, "node_modules/yargs/node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11179,7 +11756,6 @@ }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -11192,7 +11768,6 @@ }, "node_modules/yargs/node_modules/yargs-parser": { "version": "21.1.1", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -11217,11 +11792,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", + "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/types": { "name": "@byndyusoft-ui/types", "version": "0.3.0", "license": "Apache-2.0" }, + "services/http": { + "name": "@byndyusoft-ui/http", + "version": "0.0.1", + "license": "Apache-2.0", + "dependencies": { + "axios": "^1.9.0" + }, + "peerDependencies": { + "axios-mock-adapter": "^2.1.0", + "msw": "^2.8.7" + } + }, + "services/http-request": { + "name": "@byndyusoft-ui/http-request", + "version": "0.0.1", + "extraneous": true, + "license": "Apache-2.0", + "dependencies": { + "axios": "^1.9.0" + } + }, + "services/http-service": { + "name": "@byndyusoft-ui/http-service", + "version": "0.0.1", + "extraneous": true, + "license": "Apache-2.0", + "dependencies": { + "axios": "^1.9.0" + }, + "devDependencies": { + "axios-mock-adapter": "^2.1.0" + } + }, "styles/keyframes-css": { "name": "@byndyusoft-ui/keyframes-css", "version": "0.0.1", diff --git a/services/http/.npmignore b/services/http/.npmignore new file mode 100644 index 00000000..e8310385 --- /dev/null +++ b/services/http/.npmignore @@ -0,0 +1 @@ +src \ No newline at end of file diff --git a/services/http/README.md b/services/http/README.md new file mode 100644 index 00000000..9ceac235 --- /dev/null +++ b/services/http/README.md @@ -0,0 +1,98 @@ +# `@byndyusoft-ui/http` + +> Http service +### Installation + +```bash +npm i @byndyusoft-ui/http +``` + +## Usage + +### To start using this service you need to create a new class instance of http client. +There are two classes ready to be used as http clients: **HttpClientAxios** and **HttpClientFetch**. + +### Example of usage with HttpClientAxios + +```ts +import HttpClientAxios from '@byndyusoft-ui/http'; +import { http } from 'msw'; + +interface IRequestBody { + name: string; +} + +interface IResponse { + message: string; +} + +const httpClient = new HttpClientAxios({ baseUrl: 'https://base-url.com' }); + +const postData = async (name: string): Promise => await httpClient + .post() + .url('/post-some-data') + .headers({Authorization: 'Bearer token'}) + .body({name}) + .send(); +``` + +### Example of usage with HttpClientFetch + +```ts + import HttpClientFetch from '@byndyusoft-ui/http'; + + interface IResponse { + message: string; + } + + const httpClient = new HttpClientFetch({ baseUrl: 'https://base-url.com' }); + + const getData = async (month: string): Promise => await httpClient + .get() + .url('/get-some-data') + .headers({ Authorization: 'Bearer token'}) + .params({ month }) + .send(); +``` + +### You can add request, response and error interceptors + +```ts + const httpClient = new HttpClientFetch({ baseUr: 'https://base-url.com' }); + + httpClient.setRequestInterceptor(requestConfig => { + // interceptor logic + + return modifiedRequestConfig; + }); + + httpClient.setResponseInterceptor(response => { + // interceptor logic + + return modifiedResponse; + }); +``` +#### Example of token refreshing and request repeating when error 401 is received + +```ts + httpClient.setErrorInterceptor(async error => { + if (error.response?.status === HttpStatusCode.UNAUTHORIZED) { + const { data: { token } } = await httpClient + .get<{ token: string }>() + .url('/get-token-path') + .send(); + + httpClient.setHeader('Authorization', `Bearer ${token}`); + + return httpClient.requestClient({ + method: error.config?.method as HttpMethod, + url: error.config?.url, + headers: { ...error.config?.headers, Authorization: `Bearer ${token}` }, + params: error.config?.params, + body: error.config?.data as Object + }); + } + + throw error; + }); +``` diff --git a/services/http/package.json b/services/http/package.json new file mode 100644 index 00000000..31493490 --- /dev/null +++ b/services/http/package.json @@ -0,0 +1,50 @@ +{ + "name": "@byndyusoft-ui/http", + "version": "0.0.1", + "description": "Byndyusoft UI HTTP Client", + "keywords": [ + "byndyusoft", + "byndyusoft-ui", + "http", + "request", + "axios", + "fetch" + ], + "author": "Byndyusoft Frontend Developer ", + "homepage": "https://github.com/Byndyusoft/ui/tree/master/services/http#readme", + "license": "Apache-2.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/Byndyusoft/ui.git" + }, + "scripts": { + "build": "rollup --config", + "clean": "rimraf dist", + "test": "jest --config ../../jest.config.js --roots services/http/src", + "lint:check": "npm run eslint:check && npm run prettier:check && npm run stylelint:check", + "lint:fix": "npm run eslint:fix && npm run prettier:fix && npm run stylelint:fix", + "eslint:check": "eslint src --config ../../eslint.config.js", + "eslint:fix": "eslint src --config ../../eslint.config.js --fix", + "prettier:check": "prettier --check '**/*.{ts,tsx,css,scss,json}' '!**/dist/**'", + "prettier:fix": "prettier --write '**/*.{ts,tsx,css,scss,json}' '!**/dist/**'", + "stylelint:check": "stylelint '**/*.{css,scss}' --allow-empty-input", + "stylelint:fix": "stylelint '**/*.{css,scss}' --fix --allow-empty-input" + }, + "bugs": { + "url": "https://github.com/Byndyusoft/ui/issues" + }, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "msw": "^2.8.7" + }, + "dependencies": { + "axios": "^1.9.0" + }, + "msw": { + "workerDirectory": "public" + } +} diff --git a/services/http/public/mockServiceWorker.js b/services/http/public/mockServiceWorker.js new file mode 100644 index 00000000..84491eb3 --- /dev/null +++ b/services/http/public/mockServiceWorker.js @@ -0,0 +1,307 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const PACKAGE_VERSION = '2.8.7' +const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId)) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseClone.body, + headers: Object.fromEntries(responseClone.headers.entries()), + }, + }, + [responseClone.body], + ) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const requestBuffer = await request.arrayBuffer() + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive, + }, + }, + [requestBuffer], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage( + message, + [channel.port2].concat(transferrables.filter(Boolean)), + ) + }) +} + +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} diff --git a/services/http/rollup.config.js b/services/http/rollup.config.js new file mode 100644 index 00000000..87cb86d1 --- /dev/null +++ b/services/http/rollup.config.js @@ -0,0 +1,11 @@ +import typescript from '@rollup/plugin-typescript'; +import baseConfig from '../../rollup.base.config'; + +export default { + ...baseConfig, + input: ['src/index.ts'], + plugins: [ + ...baseConfig.plugins, + typescript({ tsconfig: './tsconfig.json', exclude: ['src/**/*.stories.tsx', 'src/**/*.tests.tsx', 'node_modules'] }) + ] +}; diff --git a/services/http/src/__fixtures__/httpClient.fixtures.ts b/services/http/src/__fixtures__/httpClient.fixtures.ts new file mode 100644 index 00000000..0ab42653 --- /dev/null +++ b/services/http/src/__fixtures__/httpClient.fixtures.ts @@ -0,0 +1,27 @@ +export const requestBody = { + firstParameter: 'firstParameterValue' +} + +export const successResponse = { + success: true +} + +export const baseUrl = 'https://test-url.com'; +export const getPath = '/get'; +export const getPathWithQueryParams = '/get/with-query'; +export const getPathWithError = '/get/with-error'; +export const getPathWithTimeout = '/get/with-timeout'; +export const getTokenPath = '/get/token'; +export const getDataWithAuthorizationPath = '/get/with-authorization'; +export const postPath = '/post'; +export const putPath = '/put'; +export const patchPath = '/patch'; +export const deletePath = '/delete'; + +export const baseHeaders = { Authorization: 'Bearer token' }; + +export const optionalHeaders = { Header: 'Header value'}; + +export const queryParams = { testParam: 'тестовоеЗначение' }; + +export const errorDetails = { detail_1: 'description_1', detail_2: 'description_2' }; diff --git a/services/http/src/__handlers__/httpClient.handlers.ts b/services/http/src/__handlers__/httpClient.handlers.ts new file mode 100644 index 00000000..e0485e5a --- /dev/null +++ b/services/http/src/__handlers__/httpClient.handlers.ts @@ -0,0 +1,130 @@ +import { http, HttpResponse, delay } from 'msw'; +import { + baseUrl, + getPath, + postPath, + putPath, + patchPath, + deletePath, + getPathWithQueryParams, + getPathWithError, + getPathWithTimeout, + getTokenPath, + getDataWithAuthorizationPath, + queryParams, + errorDetails, + successResponse +} from '../__fixtures__/httpClient.fixtures'; +import { HttpStatusCode } from '../types/httpStatusCode.types'; + +export const getRequest = http.get(`${baseUrl}${getPath}`, ({ request }) => { + if (request.headers.get('Authorization') !== 'Bearer token' || request.headers.get('Header') !== 'Header value') { + return new HttpResponse(null, { status: HttpStatusCode.BAD_REQUEST, statusText: 'Wrong headers' }); + } + + return HttpResponse.json({ success: true }); +}); + +export const getRequestWithQuery = http.get(`${baseUrl}${getPathWithQueryParams}`, ({ request }) => { + const url = new URL(request.url); + + const testParamValue = url.searchParams.get('testParam'); + + if (!testParamValue) { + return new HttpResponse(null, { status: HttpStatusCode.NOT_FOUND }); + } + + return HttpResponse.json({ testParam: testParamValue }); +}); + +export const getRequestWithError = http.get(`${baseUrl}${getPathWithError}`, ({ request }) => { + const url = new URL(request.url); + + const testParamValue = url.searchParams.get('testParam'); + + if (testParamValue === queryParams['testParam']) { + return new HttpResponse(JSON.stringify(errorDetails), { status: HttpStatusCode.BAD_REQUEST, headers: { 'content-type': 'application/json' } }); + } + + return HttpResponse.json(successResponse); +}); + +export const getRequestWithTimeout = http.get(`${baseUrl}${getPathWithTimeout}`, async () => { + await delay(3000); + + return HttpResponse.json(successResponse); +}); + +export const getToken = http.get(`${baseUrl}${getTokenPath}`, () => HttpResponse.json({ token: 'test_token_01' })); + +export const getDataWithAuthorization = http.get(`${baseUrl}${getDataWithAuthorizationPath}`, async ({ request }) => { + await delay(1000); + + if (request.headers.get('Authorization') !== 'Bearer test_token_01') { + return new HttpResponse(null, { status: HttpStatusCode.UNAUTHORIZED, statusText: 'Unauthorized' }); + } + + return HttpResponse.json(successResponse); +}); + +export const postRequest = http.post(`${baseUrl}${postPath}`, async ({ request }) => { + const requestBody = await request.clone().json(); + + if (requestBody && request.headers.get('Authorization') === 'Bearer token' && request.headers.get('Header') === 'Header value') { + return HttpResponse.json(requestBody); + } + + return new HttpResponse(null, { status: HttpStatusCode.BAD_REQUEST, statusText: 'No body' }); +}); + +export const putRequest = http.put(`${baseUrl}${putPath}`, async ({ request }) => { + const url = new URL(request.url) + + const testParamValue = url.searchParams.get('testParam') + + if (!testParamValue) { + return new HttpResponse(null, { status: HttpStatusCode.NOT_FOUND }) + } + + const requestBody = await request.clone().json(); + + if (requestBody && request.headers.get('Authorization') === 'Bearer token' && request.headers.get('Header') === 'Header value') { + return HttpResponse.json(requestBody); + } + + return new HttpResponse(null, { status: HttpStatusCode.BAD_REQUEST, statusText: 'No body' }); +}); + +export const patchRequest = http.patch(`${baseUrl}${patchPath}`, async ({ request }) => { + const url = new URL(request.url) + + const testParamValue = url.searchParams.get('testParam') + + if (!testParamValue) { + return new HttpResponse(null, { status: HttpStatusCode.NOT_FOUND }) + } + + const requestBody = await request.clone().json(); + + if (requestBody && request.headers.get('Authorization') === 'Bearer token' && request.headers.get('Header') === 'Header value') { + return HttpResponse.json(requestBody); + } + + return new HttpResponse(null, { status: HttpStatusCode.BAD_REQUEST, statusText: 'No body' }); +}); + +export const deleteRequest = http.delete(`${baseUrl}${deletePath}`, async ({ request }) => { + const url = new URL(request.url) + + const testParamValue = url.searchParams.get('testParam'); + + if (!testParamValue) { + return new HttpResponse(null, { status: HttpStatusCode.NOT_FOUND }); + } + + if (request.headers.get('Authorization') !== 'Bearer token' || request.headers.get('Header') !== 'Header value') { + return new HttpResponse(null, { status: HttpStatusCode.BAD_REQUEST, statusText: 'Wrong headers' }); + } + + return new HttpResponse(null, { status: HttpStatusCode.OK }); +}); diff --git a/services/http/src/__tests__/httpClientAxios.tests.ts b/services/http/src/__tests__/httpClientAxios.tests.ts new file mode 100644 index 00000000..5ed11b6c --- /dev/null +++ b/services/http/src/__tests__/httpClientAxios.tests.ts @@ -0,0 +1,237 @@ +import { setupServer } from 'msw/node'; +import * as handlers from '../__handlers__/httpClient.handlers'; +import HttpClientAxios from '../services/httpClientAxios'; +import { + baseUrl, + baseHeaders, + optionalHeaders, + queryParams, + getPath, + getPathWithQueryParams, + getPathWithTimeout, + postPath, + putPath, + patchPath, + deletePath, + requestBody, + successResponse, + getPathWithError, + errorDetails, getTokenPath, getDataWithAuthorizationPath +} from '../__fixtures__/httpClient.fixtures'; +import { HttpStatusCode } from '../types/httpStatusCode.types'; +import { HttpMethod } from '../types/httpMethod.types'; + +const server = setupServer(); + +describe('services/HttpClientAxios', () => { + beforeAll(() => { + server.listen({ onUnhandledRequest: 'error' }); + }) + + afterEach(() => { + server.resetHandlers(); + }); + + afterAll(() => { + server.close(); + }); + + test('should send GET request with correct headers, body, and URL', async () => { + server.use(handlers.getRequest); + + const httpClientInstance = new HttpClientAxios({ baseURL: baseUrl, headers: baseHeaders }); + + const response = await httpClientInstance + .get() + .url(getPath) + .headers(optionalHeaders) + .send(); + + expect(response.data).toEqual(successResponse); + }); + + test('should send GET request with correct query string', async () => { + server.use(handlers.getRequestWithQuery); + + const httpClientInstance = new HttpClientAxios({ baseURL: baseUrl }); + + const response = await httpClientInstance + .get() + .url(getPathWithQueryParams) + .params(queryParams) + .send(); + + expect(response.data).toEqual(queryParams); + }); + + test('should get correct 400 error on GET request', async () => { + server.use(handlers.getRequestWithError); + + const httpClientInstance = new HttpClientAxios({ baseURL: baseUrl }); + + const response = httpClientInstance + .get() + .url(getPathWithError) + .params(queryParams) + .send(); + + await expect(response).rejects.toMatchObject({ + code: 'ERR_BAD_REQUEST', + response: { + status: HttpStatusCode.BAD_REQUEST, + data: errorDetails + } + }); + }); + + test('should get the error when GET request timeout exceeded', async () => { + server.use(handlers.getRequestWithTimeout); + + const httpClientInstance = new HttpClientAxios({ baseURL: baseUrl, timeout: 1000 }); + + const response = httpClientInstance + .get() + .url(getPathWithTimeout) + .send(); + + await expect(response).rejects.toThrowError('Timeout of 1000ms exceeded'); + }); + + test('should get the error on request cancel', async () => { + server.use(handlers.getRequestWithTimeout); + + const httpClientInstance = new HttpClientAxios({ baseURL: baseUrl }); + + const controller = new AbortController(); + + const response = httpClientInstance + .get() + .url(getPathWithTimeout) + .signal(controller.signal) + .send(); + + controller.abort(); + + await expect(response).rejects.toThrowError('The request was cancelled'); + }); + + test('should send POST request with correct headers, body, and URL', async () => { + server.use(handlers.postRequest); + + const httpClientInstance = new HttpClientAxios({ baseURL: baseUrl, headers: baseHeaders }); + + const response = await httpClientInstance + .post() + .url(postPath) + .headers(optionalHeaders) + .body(requestBody) + .send(); + + expect(response.data).toEqual(requestBody); + }); + + test('should send PUT request with correct headers, body, URL and query string', async () => { + server.use(handlers.putRequest); + + const httpClientInstance = new HttpClientAxios({ baseURL: baseUrl, headers: baseHeaders }); + + const response = await httpClientInstance + .put() + .url(putPath) + .headers(optionalHeaders) + .params(queryParams) + .body(requestBody) + .send(); + + expect(response.data).toEqual(requestBody); + }); + + test('should send PATCH request with correct headers, body, URL and query string', async () => { + server.use(handlers.patchRequest); + + const httpClientInstance = new HttpClientAxios({ baseURL: baseUrl, headers: baseHeaders }); + + const response = await httpClientInstance + .patch() + .url(patchPath) + .headers(optionalHeaders) + .params(queryParams) + .body(requestBody) + .send(); + + expect(response.data).toEqual(requestBody); + }); + + test('should send DELETE request with correct headers, body, URL and query string', async () => { + server.use(handlers.deleteRequest); + + const httpClientInstance = new HttpClientAxios({ baseURL: baseUrl, headers: baseHeaders }); + + const response = await httpClientInstance + .delete() + .url(deletePath) + .headers(optionalHeaders) + .params(queryParams) + .send(); + + expect(response.status).toEqual(HttpStatusCode.OK); + }); + + test('should request interceptor to change authorization header before request', async () => { + server.use(handlers.getToken, handlers.getDataWithAuthorization); + + const httpClientInstance = new HttpClientAxios({ baseURL: baseUrl, headers: { Authorization: 'Bearer token' } }); + + httpClientInstance.setRequestInterceptor(async requestConfig => { + if (requestConfig.url !== getTokenPath) { + const { data: { token: newToken } } = await httpClientInstance.get<{ token: string }>().url(getTokenPath).send(); + + requestConfig.headers = { ...requestConfig.headers, Authorization: `Bearer ${newToken}` }; + + httpClientInstance.setHeader('Authorization', `Bearer ${newToken}`); + } + + return requestConfig; + }); + + const response = await httpClientInstance + .get() + .url(getDataWithAuthorizationPath) + .send(); + + expect(response.data).toEqual(successResponse); + expect(httpClientInstance.headers.Authorization).toEqual('Bearer test_token_01'); + }); + + test('should error interceptor to update token if gets 401 error and retry request', async () => { + server.use(handlers.getToken, handlers.getDataWithAuthorization); + + const httpClientInstance = new HttpClientAxios({ baseURL: baseUrl, headers: { Authorization: 'Bearer token' } }); + + httpClientInstance.setErrorInterceptor(async error => { + if (error.response?.status === HttpStatusCode.UNAUTHORIZED && error.config?.url !== getTokenPath) { + const { data: { token: newToken } } = await httpClientInstance.get<{ token: string }>().url(getTokenPath).send(); + + httpClientInstance.setHeader('Authorization', `Bearer ${newToken}`); + + return httpClientInstance.requestClient({ + method: error.config?.method as HttpMethod, + url: error.config?.url, + headers: { ...error.config?.headers, Authorization: `Bearer ${newToken}` }, + params: error.config?.params, + body: error.config?.data as Object + }); + } + + throw error; + }); + + const response = await httpClientInstance + .get() + .url(getDataWithAuthorizationPath) + .send(); + + expect(response.data).toEqual(successResponse); + expect(httpClientInstance.headers.Authorization).toEqual('Bearer test_token_01'); + }); +}); diff --git a/services/http/src/__tests__/httpClientFetch.tests.ts b/services/http/src/__tests__/httpClientFetch.tests.ts new file mode 100644 index 00000000..0ac8edc8 --- /dev/null +++ b/services/http/src/__tests__/httpClientFetch.tests.ts @@ -0,0 +1,240 @@ +import { setupServer } from 'msw/node'; +import * as handlers from '../__handlers__/httpClient.handlers'; +import HttpClientFetch from '../services/httpClientFetch'; + +import { + baseUrl, + baseHeaders, + optionalHeaders, + queryParams, + getPath, + getPathWithQueryParams, + getPathWithError, + postPath, + putPath, + patchPath, + deletePath, + requestBody, + successResponse, + errorDetails, + getPathWithTimeout, + getDataWithAuthorizationPath, + getTokenPath +} from '../__fixtures__/httpClient.fixtures'; +import { HttpStatusCode } from '../types/httpStatusCode.types'; +import { HttpMethod } from '../types/httpMethod.types'; + +const server = setupServer(); + +describe('services/HttpClientFetch', () => { + beforeAll(() => { + server.listen({ onUnhandledRequest: 'error' }); + }) + + afterEach(() => { + server.resetHandlers(); + }); + + afterAll(() => { + server.close(); + }); + + test('should send GET request with correct headers, body, and URL', async () => { + server.use(handlers.getRequest); + + const httpClientInstance = new HttpClientFetch({ baseURL: baseUrl, headers: baseHeaders }); + + const response = await httpClientInstance + .get() + .url(getPath) + .headers(optionalHeaders) + .send(); + + expect(response.data).toEqual(successResponse); + }); + + test('should send GET request with correct query string', async () => { + server.use(handlers.getRequestWithQuery); + + const httpClientInstance = new HttpClientFetch({ baseURL: baseUrl }); + + const response = await httpClientInstance + .get() + .url(getPathWithQueryParams) + .params(queryParams) + .send(); + + expect(response.data).toEqual(queryParams); + }); + + test('should get correct 400 error on GET request', async () => { + server.use(handlers.getRequestWithError); + + const httpClientInstance = new HttpClientFetch({ baseURL: baseUrl }); + + const response = httpClientInstance + .get() + .url(getPathWithError) + .params(queryParams) + .send(); + + await expect(response).rejects.toMatchObject({ + code: 'ERR_BAD_REQUEST', + response: { + status: HttpStatusCode.BAD_REQUEST, + data: errorDetails + } + }); + }); + + test('should get the error when GET request timeout exceeded', async () => { + server.use(handlers.getRequestWithTimeout); + + const httpClientInstance = new HttpClientFetch({ baseURL: baseUrl, timeout: 1000 }); + + const response = httpClientInstance + .get() + .url(getPathWithTimeout) + .send(); + + await expect(response).rejects.toThrowError('Timeout of 1000ms exceeded'); + }); + + test('should get the error on request cancel', async () => { + server.use(handlers.getRequestWithTimeout); + + const httpClientInstance = new HttpClientFetch({ baseURL: baseUrl }); + + const controller = new AbortController(); + + const response = httpClientInstance + .get() + .url(getPathWithTimeout) + .signal(controller.signal) + .send(); + + controller.abort(); + + await expect(response).rejects.toThrowError('The request was cancelled'); + }); + + test('should send POST request with correct headers, body, and URL', async () => { + server.use(handlers.postRequest); + + const httpClientInstance = new HttpClientFetch({ baseURL: baseUrl, headers: baseHeaders }); + + const response = await httpClientInstance + .post() + .url(postPath) + .headers(optionalHeaders) + .body(requestBody) + .send(); + + expect(response.data).toEqual(requestBody); + }); + + test('should send PUT request with correct headers, body, URL and query string', async () => { + server.use(handlers.putRequest); + + const httpClientInstance = new HttpClientFetch({ baseURL: baseUrl, headers: baseHeaders }); + + const response = await httpClientInstance + .put() + .url(putPath) + .headers(optionalHeaders) + .params(queryParams) + .body(requestBody) + .send(); + + expect(response.data).toEqual(requestBody); + }); + + test('should send PATCH request with correct headers, body, URL and query string', async () => { + server.use(handlers.patchRequest); + + const httpClientInstance = new HttpClientFetch({ baseURL: baseUrl, headers: baseHeaders }); + + const response = await httpClientInstance + .patch() + .url(patchPath) + .headers(optionalHeaders) + .params(queryParams) + .body(requestBody) + .send(); + + expect(response.data).toEqual(requestBody); + }); + + test('should send DELETE request with correct headers, body, URL and query string', async () => { + server.use(handlers.deleteRequest); + + const httpClientInstance = new HttpClientFetch({ baseURL: baseUrl, headers: baseHeaders }); + + const response = await httpClientInstance + .delete() + .url(deletePath) + .headers(optionalHeaders) + .params(queryParams) + .send(); + + expect(response.status).toEqual(HttpStatusCode.OK); + }); + + test('should request interceptor to change authorization header before request', async () => { + server.use(handlers.getToken, handlers.getDataWithAuthorization); + + const httpClientInstance = new HttpClientFetch({ baseURL: baseUrl, headers: { Authorization: 'Bearer token' } }); + + httpClientInstance.setRequestInterceptor(async requestConfig => { + if (requestConfig.url !== getTokenPath) { + const { data: { token: newToken } } = await httpClientInstance.get<{ token: string }>().url(getTokenPath).send(); + + requestConfig.headers = { ...requestConfig.headers, Authorization: `Bearer ${newToken}` }; + + httpClientInstance.setHeader('Authorization', `Bearer ${newToken}`); + } + + return requestConfig; + }); + + const response = await httpClientInstance + .get() + .url(getDataWithAuthorizationPath) + .send(); + + expect(response.data).toEqual(successResponse); + expect(httpClientInstance.headers.Authorization).toEqual('Bearer test_token_01'); + }); + + test('should error interceptor to update token if gets 401 error and retry request', async () => { + server.use(handlers.getToken, handlers.getDataWithAuthorization); + + const httpClientInstance = new HttpClientFetch({ baseURL: baseUrl, headers: { Authorization: 'Bearer token' } }); + + httpClientInstance.setErrorInterceptor(async error => { + if (error.response?.status === HttpStatusCode.UNAUTHORIZED && error.config?.url !== getTokenPath) { + const { data: { token: newToken } } = await httpClientInstance.get<{ token: string }>().url(getTokenPath).send(); + + httpClientInstance.setHeader('Authorization', `Bearer ${newToken}`); + + return httpClientInstance.requestClient({ + method: error.config?.method as HttpMethod, + url: error.config?.url, + headers: { ...error.config?.headers, Authorization: `Bearer ${newToken}` }, + params: error.config?.params, + body: error.config?.data as Object + }); + } + + throw error; + }); + + const response = await httpClientInstance + .get() + .url(getDataWithAuthorizationPath) + .send(); + + expect(response.data).toEqual(successResponse); + expect(httpClientInstance.headers.Authorization).toEqual('Bearer test_token_01'); + }); +}); diff --git a/services/http/src/index.ts b/services/http/src/index.ts new file mode 100644 index 00000000..a2352bf7 --- /dev/null +++ b/services/http/src/index.ts @@ -0,0 +1,3 @@ +export { default as HttpClientAxios } from './services/httpClientAxios'; +export { default as HttpClientFetch } from './services/httpClientFetch'; +export { HttpStatusCode } from './types/httpStatusCode.types'; diff --git a/services/http/src/services/httpClient.ts b/services/http/src/services/httpClient.ts new file mode 100644 index 00000000..fce882bc --- /dev/null +++ b/services/http/src/services/httpClient.ts @@ -0,0 +1,102 @@ +import { HttpRequest, HttpRequestWithBody, IRequestOptions } from './httpRequest'; +import { HttpMethod } from '../types/httpMethod.types'; +import { + IHttpClientResponse, + HttpClientError, + THeaders, + TQueryParams, + IRequestConfig +} from '../types/httpClient.types'; + +export const DEFAULT_REQUEST_TIMEOUT = 60000; + +export interface IHttpClientInit { + baseURL?: string; + headers?: THeaders; + timeout?: number; +} + +type TRequestInterceptor = (request: IRequestConfig) => Promise; +type TResponseInterceptor = (response: IHttpClientResponse) => Promise>; +type TErrorInterceptor = (error: HttpClientError) => Promise>; + +export abstract class HttpClient { + baseURL: string; + headers: THeaders; + timeout: number; + protected requestInterceptor?: TRequestInterceptor; + protected responseInterceptor?: TResponseInterceptor; + protected errorInterceptor?: TErrorInterceptor; + + protected constructor({ baseURL, headers, timeout }: IHttpClientInit) { + this.baseURL = baseURL ?? ''; + this.headers = headers ?? {}; + this.timeout = timeout ?? DEFAULT_REQUEST_TIMEOUT; + } + + abstract requestClient: (arg: IRequestOptions) => Promise>; + + static buildQueryString(queryParams: TQueryParams) { + const params = Object.keys(queryParams); + if (params.length > 0) { + return `?${ + params + .map(param => `${encodeURIComponent(param)}=${encodeURIComponent(queryParams[param])}`) + .join('&') + }`; + } + return ''; + } + + setHeader(key: string, value: string): void { + this.headers[key] = value; + }; + + get() { + return new HttpRequest(this.requestClient, HttpMethod.GET); + } + + post() { + return new HttpRequestWithBody(this.requestClient, HttpMethod.POST); + } + + put() { + return new HttpRequestWithBody(this.requestClient, HttpMethod.PUT); + } + + patch() { + return new HttpRequestWithBody(this.requestClient, HttpMethod.PATCH); + } + + delete() { + return new HttpRequest(this.requestClient, HttpMethod.DELETE); + } + + setRequestInterceptor(interceptor: TRequestInterceptor): void { + this.requestInterceptor = interceptor; + } + + setResponseInterceptor(interceptor: TResponseInterceptor): void { + this.responseInterceptor = interceptor; + } + + setErrorInterceptor(interceptor: TErrorInterceptor): void { + this.errorInterceptor = interceptor; + } + + protected async processRequest(config: IRequestConfig): Promise { + return this.requestInterceptor?.(config) ?? config; + } + + protected async processResponse(response: IHttpClientResponse): Promise> { + return this.responseInterceptor?.(response) ?? response; + } + + protected async processError(error: HttpClientError): Promise> { + if (this.errorInterceptor) { + return this.errorInterceptor(error); + } + + throw error; + } +} diff --git a/services/http/src/services/httpClientAxios/httpClientAxios.ts b/services/http/src/services/httpClientAxios/httpClientAxios.ts new file mode 100644 index 00000000..63ca7e29 --- /dev/null +++ b/services/http/src/services/httpClientAxios/httpClientAxios.ts @@ -0,0 +1,62 @@ +import axios, { AxiosError } from 'axios'; +import { HttpClient, IHttpClientInit } from '../httpClient'; +import { IRequestOptions } from '../httpRequest'; +import { HttpClientError, IHttpClientResponse } from '../../types/httpClient.types'; +import { combineAbortSignals } from '../../utilities/httpClient.utilities'; + +export class HttpClientAxios extends HttpClient { + requestClient; + + constructor(initSettings: IHttpClientInit) { + super(initSettings); + + this.requestClient = async (options: IRequestOptions): Promise> => { + const processedConfig = await this.processRequest({ + url: options.url, + method: options.method, + baseURL: this.baseURL, + headers: { ...this.headers, ...options.headers }, + params: options.params, + data: options.body + }); + + const timeoutSignal = AbortSignal.timeout(this.timeout); + const combinedSignals = combineAbortSignals(timeoutSignal, options.signal); + + return axios({ + ...processedConfig, + signal: combinedSignals + }) + .then(response => this.processResponse({ + data: response.data, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(Object.entries(response.headers)), + config: processedConfig + })) + .catch((error: AxiosError) => { + let message: string; + + if (error.code === 'ERR_CANCELED') { + message = timeoutSignal.aborted + ? `Timeout of ${this.timeout}ms exceeded` + : 'The request was cancelled'; + } else { + message = error.message; + } + + return this.processError(new HttpClientError({ + message: message, + code: error.code, + response: error.response ? { + data: error.response.data, + status: error.response.status, + statusText: error.response.statusText, + headers: Object.fromEntries(Object.entries(error.response.headers)) + } : undefined, + config: processedConfig + })); + }); + }; + } +} diff --git a/services/http/src/services/httpClientAxios/index.ts b/services/http/src/services/httpClientAxios/index.ts new file mode 100644 index 00000000..79f55261 --- /dev/null +++ b/services/http/src/services/httpClientAxios/index.ts @@ -0,0 +1,2 @@ +import { HttpClientAxios as AxiosClient } from './httpClientAxios'; +export default AxiosClient; diff --git a/services/http/src/services/httpClientFetch/httpClientFetch.ts b/services/http/src/services/httpClientFetch/httpClientFetch.ts new file mode 100644 index 00000000..e7c4c053 --- /dev/null +++ b/services/http/src/services/httpClientFetch/httpClientFetch.ts @@ -0,0 +1,86 @@ +import { IRequestOptions } from '../httpRequest'; +import { HttpClient, IHttpClientInit } from '../httpClient'; +import { IHttpClientResponse, HttpClientError } from '../../types/httpClient.types'; +import { combineAbortSignals } from '../../utilities/httpClient.utilities'; + +export class HttpClientFetch extends HttpClient { + requestClient; + + constructor(initSettings: IHttpClientInit) { + super(initSettings); + + this.requestClient = async (options: IRequestOptions): Promise> => { + const processedConfig = await this.processRequest({ + url: options.url, + method: options.method, + baseURL: this.baseURL, + headers: { ...this.headers, ...options.headers }, + params: options.params, + data: options.body + }); + + const url = encodeURI(`${processedConfig.baseURL}${processedConfig.url}`) + HttpClient.buildQueryString(processedConfig.params ?? {}); + const timeoutSignal = AbortSignal.timeout(this.timeout); + const combinedSignals = combineAbortSignals(timeoutSignal, options.signal); + + const response = await fetch(url, { + method: processedConfig.method, + headers: processedConfig.headers, + body: JSON.stringify(processedConfig.data), + signal: combinedSignals + }).catch(error => { + let message: string; + + if (error.name === 'AbortError') { + message = timeoutSignal.aborted + ? `Timeout of ${this.timeout}ms exceeded` + : 'The request was cancelled'; + } else { + message = error.message; + } + + throw new HttpClientError({ + code: error.code, + message: message, + config: processedConfig + }); + }); + + const contentType = response.headers.get('Content-Type'); + + let data; + + if (contentType?.includes('application/json')) { + data = await response.json(); + } else if (contentType?.includes('text/')) { + data = (await response.text()); + } else { + data = (await response.blob()); + } + + if (!response.ok) { + // TODO: через интерцептор проходят только те ошибки, которые генерируются если response.ok === false, + // надо обсудить достаточно ли этого + return this.processError(new HttpClientError({ + message: `Request failed with status ${response.status}`, + code: `ERR_${response.statusText.toUpperCase().replace(' ', '_')}`, + response: { + data: data as E, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + }, + config: processedConfig + })); + } + + return await this.processResponse({ + data: data as R, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + config: processedConfig + }); + }; + } +} diff --git a/services/http/src/services/httpClientFetch/index.ts b/services/http/src/services/httpClientFetch/index.ts new file mode 100644 index 00000000..3e5275f6 --- /dev/null +++ b/services/http/src/services/httpClientFetch/index.ts @@ -0,0 +1,2 @@ +import { HttpClientFetch as FetchClient } from './httpClientFetch'; +export default FetchClient; diff --git a/services/http/src/services/httpRequest.ts b/services/http/src/services/httpRequest.ts new file mode 100644 index 00000000..2b6af910 --- /dev/null +++ b/services/http/src/services/httpRequest.ts @@ -0,0 +1,82 @@ +import { HttpMethod } from '../types/httpMethod.types'; +import { IHttpClientResponse, THeaders, TQueryParams } from '../types/httpClient.types'; + +export interface IRequestOptions { + method: HttpMethod; + url?: string; + headers?: THeaders; + params?: TQueryParams; + signal?: AbortSignal; + body?: Object; +} + +export type TRequestClient = (arg: IRequestOptions) => Promise>; + +export class HttpRequest { + protected requestClient: TRequestClient; + protected urlValue: string = ''; + protected method: HttpMethod; + protected headersValue?: THeaders; + protected paramsValue?: TQueryParams; + protected abortSignal?: AbortSignal; + + constructor(requestClient: TRequestClient, method: HttpMethod) { + this.requestClient = requestClient; + this.method = method; + } + + url(url: string): this { + this.urlValue = url; + + return this; + } + + headers(headers: THeaders): this { + this.headersValue = { ...this.headersValue, ...headers }; + + return this; + } + + params(queryParams: TQueryParams): this { + this.paramsValue = { ...this.paramsValue, ...queryParams }; + + return this; + } + + signal(abortSignal: AbortSignal): this { + this.abortSignal = abortSignal; + + return this; + } + + send(): Promise> { + return this.requestClient({ + url: this.urlValue, + method: this.method, + headers: this.headersValue, + params: this.paramsValue, + signal: this.abortSignal + }); + } +} + +export class HttpRequestWithBody extends HttpRequest { + private bodyValue?: object = {}; + + body(body: B): this { + this.bodyValue = body; + + return this; + } + + send(): Promise> { + return this.requestClient({ + url: this.urlValue, + method: this.method, + headers: this.headersValue, + params: this.paramsValue, + signal: this.abortSignal, + body: this.bodyValue + }) + } +} diff --git a/services/http/src/types/httpClient.types.ts b/services/http/src/types/httpClient.types.ts new file mode 100644 index 00000000..343f5bfd --- /dev/null +++ b/services/http/src/types/httpClient.types.ts @@ -0,0 +1,42 @@ +import { HttpStatusCode } from './httpStatusCode.types'; + +export type THeaders = Record; + +export type TQueryParamValue = string | number | boolean; + +export type TQueryParams = Record; + +export interface IRequestConfig { + url?: string; + method?: string; + baseURL?: string; + headers?: THeaders; + params?: TQueryParams; + data?: D; +} + +export interface IHttpClientResponse { + data: T; + status: HttpStatusCode; + statusText: string; + headers: THeaders; + config?: IRequestConfig; +} + +export class HttpClientError extends Error { + code?: string; + response?: IHttpClientResponse; + config?: IRequestConfig + + constructor(args: { + message?: string; + code?: string; + response?: IHttpClientResponse; + config?: IRequestConfig; + }) { + super(args.message); + this.code = args.code; + this.response = args.response; + this.config = args.config; + }; +} diff --git a/services/http/src/types/httpMethod.types.ts b/services/http/src/types/httpMethod.types.ts new file mode 100644 index 00000000..80fc9e33 --- /dev/null +++ b/services/http/src/types/httpMethod.types.ts @@ -0,0 +1,9 @@ +export enum HttpMethod { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + PATCH = 'PATCH', + DELETE = 'DELETE', + OPTIONS = 'OPTIONS', + HEAD = 'HEAD' +} diff --git a/services/http/src/types/httpStatusCode.types.ts b/services/http/src/types/httpStatusCode.types.ts new file mode 100644 index 00000000..00f52143 --- /dev/null +++ b/services/http/src/types/httpStatusCode.types.ts @@ -0,0 +1,46 @@ +export enum HttpStatusCode { + OK = 200, + CREATED = 201, + ACCEPTED = 202, + NON_AUTHORITATIVE_INFORMATION = 203, + NO_CONTENT = 204, + RESET_CONTENT = 205, + PARTIAL_CONTENT = 206, + AMBIGUOUS = 300, + MOVED_PERMANENTLY = 301, + FOUND = 302, + SEE_OTHER = 303, + NOT_MODIFIED = 304, + TEMPORARY_REDIRECT = 307, + PERMANENT_REDIRECT = 308, + BAD_REQUEST = 400, + UNAUTHORIZED = 401, + PAYMENT_REQUIRED = 402, + FORBIDDEN = 403, + NOT_FOUND = 404, + METHOD_NOT_ALLOWED = 405, + NOT_ACCEPTABLE = 406, + PROXY_AUTHENTICATION_REQUIRED = 407, + REQUEST_TIMEOUT = 408, + CONFLICT = 409, + GONE = 410, + LENGTH_REQUIRED = 411, + PRECONDITION_FAILED = 412, + PAYLOAD_TOO_LARGE = 413, + URI_TOO_LONG = 414, + UNSUPPORTED_MEDIA_TYPE = 415, + REQUESTED_RANGE_NOT_SATISFIABLE = 416, + EXPECTATION_FAILED = 417, + I_AM_A_TEAPOT = 418, + MISDIRECTED = 421, + UNPROCESSABLE_ENTITY = 422, + FAILED_DEPENDENCY = 424, + PRECONDITION_REQUIRED = 428, + TOO_MANY_REQUESTS = 429, + INTERNAL_SERVER_ERROR = 500, + NOT_IMPLEMENTED = 501, + BAD_GATEWAY = 502, + SERVICE_UNAVAILABLE = 503, + GATEWAY_TIMEOUT = 504, + HTTP_VERSION_NOT_SUPPORTED = 505 +} diff --git a/services/http/src/types/token.types.ts b/services/http/src/types/token.types.ts new file mode 100644 index 00000000..ee4e09c8 --- /dev/null +++ b/services/http/src/types/token.types.ts @@ -0,0 +1,22 @@ +export type TValueOrGetter = T extends Function ? never : T | (() => T); +export type TGetter = () => T; + +export interface ITokenPayload { + accessToken: string; + refreshToken: string; + expiresIn: number; +} + +export type TAccessTokenGetter = TGetter; +export type TRefreshTokenGetter = TGetter; +export type TTokenExpireTimeGetter = TGetter; +export type TTokenRefreshUrlGetter = string; + +export interface ITokenData { + accessToken: TAccessTokenGetter; + refreshToken: TRefreshTokenGetter; + expireTime: TTokenExpireTimeGetter; + tokenRefreshUrl: TTokenRefreshUrlGetter; + setNewTokenPayload: (arg: ITokenPayload) => void; + clearTokens: () => void; +} diff --git a/services/http/src/utilities/httpClient.utilities.ts b/services/http/src/utilities/httpClient.utilities.ts new file mode 100644 index 00000000..49076cf5 --- /dev/null +++ b/services/http/src/utilities/httpClient.utilities.ts @@ -0,0 +1,15 @@ +export const combineAbortSignals = (...signals: Array): AbortSignal => { + const controller = new AbortController(); + + signals.forEach(signal => { + if (!signal) return; + + if (signal.aborted) { + controller.abort(); + } else { + signal.addEventListener('abort', () => controller.abort()); + } + }); + + return controller.signal; +}; diff --git a/services/http/tsconfig.json b/services/http/tsconfig.json new file mode 100644 index 00000000..567aaea9 --- /dev/null +++ b/services/http/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationDir": "dist", + "outDir": "dist", + "module": "commonjs" + }, + "include": [ + "../../types.d.ts", + "src" + ] +} diff --git a/services/http/vitest.config.mjs b/services/http/vitest.config.mjs new file mode 100644 index 00000000..16a7a659 --- /dev/null +++ b/services/http/vitest.config.mjs @@ -0,0 +1,13 @@ +import { defineProject, mergeConfig } from 'vitest/config'; +import configShared from '../../vitest.config.mjs'; + +/** + * vitest.config for correct vitest workspace detection. + * export default configShared works too. + */ +export default mergeConfig(configShared, defineProject({ + test: { + include: ['**/*.tests.(ts|tsx)'], + setupFiles: ['../../setupTests.ts'], + } +})); diff --git a/vitest.config.mjs b/vitest.config.mjs index 918d8823..46d26600 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -9,12 +9,12 @@ export default defineConfig({ globals: true, environment: 'jsdom', //https://vitest.dev/guide/workspace - workspace: [ - 'hooks/use-timeout', //workspace example. 'hooks/*' plus separate vitest.config.ts in each workspace like in README.md will work too. - getWorkspaceConfig('components'), - getWorkspaceConfig('hooks'), - getWorkspaceConfig('packages') - ], + // workspace: [ + // 'hooks/use-timeout', //workspace example. 'hooks/*' plus separate vitest.config.mjs in each workspace like in README.md will work too. + // getWorkspaceConfig('components'), + // getWorkspaceConfig('hooks'), + // getWorkspaceConfig('packages') + // ], include: ['src/**/*.{test,tests,spec}.[jt]s?(x)', 'src/**/__tests__/**.*'], } });