diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..35f7d73a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,93 @@ +# AGENTS.md + +## Monorepo structure + +Turborepo + npm workspaces. Each component, hook, service, and style package is a separate npm package under `@byndyusoft-ui/` scope. + +| Directory | Purpose | Build tool | +| -------------- | ------------------------------------------------------------- | ----------------------------------------- | +| `components/*` | React UI components | Rollup (`rollup --config`) | +| `hooks/*` | React hooks | tsc (`tsc --project tsconfig.build.json`) | +| `packages/*` | Shared types (`@byndyusoft-ui/types`) | tsc | +| `services/*` | Service packages (e.g. local-storage) | tsc | +| `styles/*` | CSS/style utilities (reset-css, keyframes-css, css-utilities) | Rollup | + +Package entry point is always `src/index.ts`, built output goes to `dist/`. + +## Commands + +```bash +npm install # install deps +npx postinstall # init husky git hooks (also runs automatically on npm install) +npm run build # clean + build all packages via turbo (must run after npm install) +npm start # storybook dev server on localhost:6009 +npm test # vitest run with typecheck (all workspaces) +npm run test:watch # vitest watch with typecheck +npm run lint:check # eslint + stylelint + prettier check (all packages) +npm run lint:fix # eslint + stylelint + prettier fix (all packages) +npm run prettier:check # prettier check only +npm run prettier:fix # prettier fix only +npm run eslint:check # eslint only +npm run eslint:fix # eslint fix only +npm run stylelint:check # stylelint only +npm run stylelint:fix # stylelint fix only +npm run set-changes # interactive changeset creation (changeset) +npm run update-packages-versions # apply changesets (bump versions + changelogs) +npm run publish # changeset publish to npm +``` + +### Run a single package's tests + +```bash +npm test -w +# Example: npm test -w @byndyusoft-ui/use-timeout +``` + +The `` is the npm `name` field from the package's `package.json`. + +### CI order + +lint:check → test → build → build-storybook + +## Creating new entities + +Use hygen templates: + +```bash +npx hygen create component # scaffolds a new component under components/ +npx hygen create hook # scaffolds a new hook under hooks/ +``` + +Generated packages include `package.json`, `tsconfig.json`, `src/` with boilerplate, and a `rollup.config.mjs` (components) or `tsconfig.build.json` (hooks). + +## Testing + +- Vitest with `globals: true` and `jsdom` environment +- Test files use `*.tests.ts(x)` or `*.test.ts(x)` or `*.spec.ts(x)` patterns +- Type-check test files use `*.tests-d.ts` pattern (configured in root `vitest.config.mjs`) +- Setup: `setupTests.ts` (imports `@testing-library/jest-dom` and `vitest-localstorage-mock`) +- Packages with local `vitest.config.mjs` use `defineProject` + `mergeConfig` from root config + +## Style & formatting + +- Prettier: 4-space indent, single quotes, no trailing commas, 120 char width (2-space for JSON, double quotes for SCSS/CSS) +- ESLint: `@byndyusoft/eslint-config/typescript` + `typescript-style-frontend` + `react` + `react-testing` presets +- Stylelint: `@byndyusoft/stylelint-config` with SCSS extensions, `color-named` rule disabled +- Commit messages: conventional commits (enforced by `@commitlint/config-conventional`) +- Pre-commit hook: lint-staged runs prettier on staged files + +## Publishing & releases + +- Uses Changesets: `npm run set-changes` → `npm run update-packages-versions` → `npm run publish` +- Changesets config: `baseBranch: "master"`, `access: "public"`, `updateInternalDependencies: "patch"` +- Packages are published to npm under `@byndyusoft-ui/` scope + +## Key quirks + +- Root `lint-staged.config.js` has a typo: matches `{ts,tsx,js,jsx,json,css,scss,md}` without glob prefix — uses `prettier` command (not `prettier --write`), meaning it only checks, not fixes, on pre-commit +- React 17 peer dependency (not React 18) +- Node 20 required (enforced in CI and Docker build) +- Components use CSS Modules for stories (`*.module.css`) alongside SCSS for component styles (`*.scss`) +- Storybook runs on port **6009** (not the default 6006) +- ESLint config path in workspace packages references `../../eslint.config.js` — shared root config +- `eslint.config.js` overrides relax rules for test/story files (disables `no-magic-numbers`, `react/button-has-type`, `react/forbid-dom-props`, warns on `explicit-module-boundary-types`) diff --git a/package-lock.json b/package-lock.json index ab5d5097..ac85f0c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "react": "^17.0.2", "rollup": "^4.41.1", "rollup-plugin-auto-external": "^2.0.0", + "rollup-plugin-dts": "^6.5.1", "rollup-plugin-postcss": "^4.0.2", "sass": "^1.89.0", "storybook": "^8.6.4", @@ -223,7 +224,7 @@ }, "hooks/use-local-storage": { "name": "@byndyusoft-ui/use-local-storage", - "version": "0.1.0", + "version": "0.2.0", "license": "ISC", "dependencies": { "@byndyusoft-ui/local-storage": "^0.1.0", @@ -774,6 +775,10 @@ "resolved": "components/highlighter", "link": true }, + "node_modules/@byndyusoft-ui/http-client": { + "resolved": "services/http-client", + "link": true + }, "node_modules/@byndyusoft-ui/keyframes-css": { "resolved": "styles/keyframes-css", "link": true @@ -2502,6 +2507,78 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "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==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@inquirer/external-editor": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.2.tgz", @@ -2541,6 +2618,34 @@ "url": "https://opencollective.com/express" } }, + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2633,6 +2738,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -2644,9 +2760,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, @@ -2857,6 +2973,31 @@ "react": ">=16" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "license": "MIT", + "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/@mswjs/interceptors/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==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -2918,6 +3059,31 @@ "node": ">=12.4.0" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", @@ -4891,6 +5057,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", @@ -6495,6 +6678,16 @@ "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==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -6691,6 +6884,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cosmiconfig": { "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", @@ -8797,6 +9004,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", @@ -8814,6 +9038,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "dev": true, @@ -9377,6 +9611,16 @@ "dev": true, "license": "MIT" }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/happy-dom": { "version": "17.6.3", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-17.6.3.tgz", @@ -9502,6 +9746,17 @@ "node": ">= 0.4" } }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, "node_modules/hookified": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.10.0.tgz", @@ -10146,6 +10401,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==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -11274,13 +11536,13 @@ } }, "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/make-error": { @@ -11514,6 +11776,110 @@ "dev": true, "license": "MIT" }, + "node_modules/msw": { + "version": "2.14.6", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.6.tgz", + "integrity": "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.11", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "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/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/msw/node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw/node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/msw/node_modules/type-fest": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -11882,6 +12248,13 @@ "dev": true, "license": "MIT" }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -12135,6 +12508,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==", + "dev": true, + "license": "MIT" + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -13634,6 +14014,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/rettime": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", + "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -13838,6 +14225,72 @@ "semver": "bin/semver" } }, + "node_modules/rollup-plugin-dts": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-6.5.1.tgz", + "integrity": "sha512-jODTXp3H7MK/Ur/ErtsrQ0G1GvaCmc3du+y5pNrdBMf6d7HlL2Nd/N6TkEr+f75CkUj01zEoEd7y2elH0eHi1Q==", + "dev": true, + "license": "LGPL-3.0-only", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.5.5", + "convert-source-map": "^2.0.0", + "magic-string": "^0.30.21" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/Swatinem" + }, + "optionalDependencies": { + "@babel/code-frame": "^8.0.0" + }, + "peerDependencies": { + "@typescript/typescript6": "^6", + "rollup": "^3 || ^4", + "typescript": "^4.5 || ^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "@typescript/typescript6": { + "optional": true + } + } + }, + "node_modules/rollup-plugin-dts/node_modules/@babel/code-frame": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/rollup-plugin-dts/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/rollup-plugin-dts/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/rollup-plugin-postcss": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/rollup-plugin-postcss/-/rollup-plugin-postcss-4.0.2.tgz", @@ -14075,6 +14528,13 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -14417,6 +14877,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", @@ -14465,6 +14935,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==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -15267,6 +15744,19 @@ "node": ">=8" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/term-size": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", @@ -15848,6 +16338,16 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -16770,6 +17270,17 @@ "version": "0.3.0", "license": "Apache-2.0" }, + "services/http-client": { + "name": "@byndyusoft-ui/http-client", + "version": "0.0.1", + "license": "Apache-2.0", + "devDependencies": { + "msw": "^2.4.9" + }, + "engines": { + "node": ">=20" + } + }, "services/local-storage": { "name": "@byndyusoft-ui/local-storage", "version": "0.1.0", diff --git a/package.json b/package.json index 57862479..4e0b44c8 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "react": "^17.0.2", "rollup": "^4.41.1", "rollup-plugin-auto-external": "^2.0.0", + "rollup-plugin-dts": "^6.5.1", "rollup-plugin-postcss": "^4.0.2", "sass": "^1.89.0", "storybook": "^8.6.4", diff --git a/services/http-client/CHANGELOG.md b/services/http-client/CHANGELOG.md new file mode 100644 index 00000000..b82c0ea6 --- /dev/null +++ b/services/http-client/CHANGELOG.md @@ -0,0 +1,14 @@ +# @byndyusoft-ui/http-client + +## 0.1.0 + +### Minor Changes + +- Подготовлен первый публичный релиз HTTP-клиента: + + - добавлены неизменяемый request builder и явный выбор формата ответа через `asJson()`, `asText()`, `asBlob()`, `asArrayBuffer()` и `asStream()`; + - FetchAdapter установлен по умолчанию, а Fetch- и XHR-адаптеры получили отдельные настройки, credentials/CORS-контракт и типизированные progress callbacks; + - добавлены scoped-клиенты через `withAdapter()`, hooks запросов и ответов, отмена и таймауты; + - реализована типизированная модель ошибок с guards, конфигурацией запроса, телом HTTP-ошибки и исходной причиной; + - расширена поддержка body и query-параметров, включая FormData, URLSearchParams, Blob, ArrayBuffer и числовые/логические параметры; + - добавлена dual ESM/CommonJS-сборка с `exports`-map, tree-shaking и требованием Node.js 20 или новее. diff --git a/services/http-client/DECISIONS.md b/services/http-client/DECISIONS.md new file mode 100644 index 00000000..1f823523 --- /dev/null +++ b/services/http-client/DECISIONS.md @@ -0,0 +1,473 @@ +# Принятые решения — @byndyusoft-ui/http-client + +Этот файл фиксирует архитектурные решения, которые не являются активными задачами. Для новых записей используется следующий формат: номер, статус, дата, решение, причина, последствия и условие пересмотра. + +## D-001 — Настройки, специфичные для адаптера + +Статус: принято +Дата: 2026-08-21 + +### Решение + +Fetch- и XHR-специфичные параметры передаются в конструкторы `FetchAdapter` и `XhrAdapter`, а не в `HttpRequestBuilder` или общий `IHttpRequestConfig`. + +### Причина + +`HttpRequestBuilder` формирует переносимую конфигурацию, общую для обоих транспортов. Fetch-параметры, такие как `cache`, `redirect` и `keepalive`, не имеют эквивалентной семантики в XHR. Размещение их в builder создавало бы API с настройками, неработающими для части адаптеров. + +### Последствия + +- `withCredentials` остаётся общей настройкой `HttpClient` и отдельного запроса, поскольку поддерживается Fetch и XHR. +- `IFetchAdapterOptions` и `IXhrAdapterOptions` являются публичными контрактами настроек адаптеров. +- Конфигурация запроса и `HttpClient` имеет приоритет над настройками XHR-адаптера для общих полей: `timeout`, `responseType`, `withCredentials`. +- `withCredentials` запроса имеет приоритет над `credentials` в `FetchAdapter`. + +### Когда пересматривать + +При добавлении третьего транспорта или при подтверждённой необходимости задавать Fetch-специфичные параметры для одного запроса. + +## D-002 — Языковое соглашение + +Статус: принято +Дата: 2026-08-21 + +### Решение + +Комментарии и JSDoc в исходном коде пакета пишутся на английском языке. Текст пользовательской документации пакета — `README.md`, `TODO.md` и `DECISIONS.md` — пишется на русском языке. + +### Причина + +Англоязычные комментарии и JSDoc составляют публичный интерфейс npm-пакета и понятны международной аудитории. Планирование и архитектурный контекст ведутся на русском языке, на котором работает команда. + +### Последствия + +- В комментариях и JSDoc исходников не используется русский текст. +- В пользовательской документации не используется английский связный текст; идентификаторы, имена API, значения и фрагменты кода сохраняют исходное написание. +- Новый `README.md` из задачи P0-1 должен быть написан на русском языке. + +### Когда пересматривать + +При изменении основной аудитории документации или языка работы команды. + +## D-003 — Типизация данных ответа через `responseType` + +Статус: не принято +Дата: 2026-08-21 + +### Решение + +JSON-ответы продолжают типизироваться явно через `execute()`. Для детерминированных `responseType` (`text`, `blob`, `arrayBuffer`, `formData`, `stream`) тип данных выводится из выбранного `responseType` и не зависит от переданного generic. + +Предполагаемая модель типов: + +```ts +type THttpResponseData = TResponseType extends 'text' + ? string + : TResponseType extends 'blob' + ? Blob + : TResponseType extends 'arrayBuffer' + ? ArrayBuffer + : TResponseType extends 'formData' + ? FormData + : TResponseType extends 'stream' + ? ReadableStream + : TJson; +``` + +Примеры ожидаемого API: + +```ts +httpClient.get('/users').execute(); // Promise> +httpClient.get('/report').responseType('blob').execute(); // Promise> +httpClient.get('/status').responseType('text').execute(); // Promise> +``` + +### Причина + +Полное неявное выведение типа JSON противоречит ранее принятому подходу с явным указанием типа ответа. Вместе с тем browser-типы уже однозначно определены платформой. Их вывод исключит рассинхрон, при котором `.responseType('blob').execute()` ошибочно обещает строку. + +### Последствия + +- `HttpRequestBuilder` будет параметризован выбранным `responseType` только на уровне TypeScript. +- Fluent-методы должны сохранять параметр response type. +- Runtime-конфигурация запросов и адаптеры не изменятся. +- Для JSON сохраняется явный `execute()`; для прочих поддерживаемых типов итоговый тип задаётся `responseType`. + +### Когда пересматривать + +При появлении пользовательских парсеров ответа или при решении перейти к полностью неявному выведению JSON-типа. + +## D-004 — Типизация тела HTTP-ошибки через generic-guard + +Статус: не принято +Дата: 2026-08-21 + +### Решение + +Предлагается разрешить вызывающему коду указывать ожидаемый тип тела HTTP-ошибки через generic-параметр `isHttpResponseError()`: + +```ts +export function isHttpResponseError(error: unknown): error is HttpResponseError { + return error instanceof HttpResponseError; +} +``` + +Пример ожидаемого API: + +```ts +interface IValidationError { + message: string; + errors: Record; +} + +try { + await httpClient.post('/users').body(data).asJson().execute(); +} catch (error) { + if (isHttpResponseError(error) && (error.status === 400 || error.status === 422)) { + const validation = error.data; // IValidationError | undefined + } +} +``` + +Вызов guard без generic-параметра должен сохранять текущий тип `HttpResponseError`. + +### Причина + +Fetch- и XHR-адаптеры могут разобрать тело ошибки как JSON или текст, но не могут определить его прикладную схему. TypeScript также не поддерживает отдельный тип отклонения для `Promise`, поэтому generic ошибки в `execute()` не типизирует переменную в `catch`. Указание ожидаемой схемы непосредственно при обработке ошибки локализует типовое утверждение в месте использования. + +### Последствия + +- Адаптеры продолжат создавать `HttpResponseError`; их runtime-поведение не изменится. +- Generic-параметр guard будет доверенным TypeScript-сужением и не станет проверять структуру `data` во время выполнения. +- `data` после сужения сохранит возможность отсутствия: `T | undefined`. +- Проверка `instanceof` останется единственным runtime-критерием принадлежности к `HttpResponseError`. +- Понадобятся type-тесты для вызовов guard с generic-параметром и без него, а также runtime-тест ответа `400` или `422` с JSON-телом. + +### Когда пересматривать + +При необходимости гарантировать схему тела ошибки во время выполнения, поддержать разные схемы для разных статусов или перейти к API с пользовательским валидатором данных. Последний вариант принят в D-009. + +## D-005 — Оркестрация повторных запросов в отдельном классе + +Статус: принято +Дата: 2026-08-22 + +### Решение + +Механизм повторных запросов из P2-4 будет реализован в отдельном классе, отвечающем за оркестрацию запросов. Retry-логика не будет встраиваться непосредственно в `HttpClient`, транспортные адаптеры или hooks. + +Точное имя класса и его публичный API будут определены при проектировании P2-4. + +### Причина + +Повтор запроса является политикой выполнения нескольких попыток, а не обязанностью одного HTTP-запроса или транспорта. Отдельный класс позволит управлять количеством попыток, задержками, `Retry-After`, отменой и безопасностью повторов независимо от Fetch/XHR и базового жизненного цикла `HttpClient`. + +### Последствия + +- `HttpClient` продолжит выполнять одну попытку запроса. +- `FetchAdapter` и `XhrAdapter` не получат retry-логику. +- Класс оркестрации будет использовать публичный API клиента и обрабатывать результаты отдельных попыток. +- Политика повторов останется opt-in. +- Правила повторения HTTP-методов, обработка `AbortSignal`, backoff и `Retry-After` должны быть спроектированы отдельно в рамках P2-4. + +### Когда пересматривать + +При появлении общего middleware-конвейера, который сможет предоставить эквивалентную изоляцию retry-политики без усложнения `HttpClient` и адаптеров. + +## D-006 — Запрет `body(undefined)` + +Статус: принято +Дата: 2026-08-22 + +### Решение + +Явный вызов `HttpRequestBuilder.body(undefined)` считается ошибкой и синхронно выбрасывает `RequestBuilderError` с кодом `INVALID_BODY`. + +Запросы POST, PUT, PATCH и DELETE без вызова `body()` остаются допустимыми. Значение `null` является осмысленным JSON-телом и сериализуется как `null`. + +### Причина + +Отсутствие вызова `body()` уже выражает запрос без тела. Явная передача `undefined` не добавляет полезной семантики и чаще указывает на ошибку подготовки данных, которую безопаснее обнаружить до отправки запроса. + +### Последствия + +- `HttpRequestBuilder.body()` проверяет `undefined` до изменения конфигурации. +- Полная конфигурация запроса с собственным полем `data: undefined`, включая результат request-hook, считается невалидной. +- Транспортные адаптеры сохраняют защитное поведение и не отправляют тело, если используются напрямую с `data: undefined`. +- Тип параметра `body()` пока остаётся `unknown`; контракт гарантируется runtime-проверкой. +- Предыдущее поведение, при котором `body(undefined)` сохранял поле `data`, больше не поддерживается. + +### Когда пересматривать + +При появлении отдельного явного API для очистки ранее установленного тела или подтверждённого сценария, где `undefined` должен означать отсутствие тела. + +## D-007 — Выбор формата ответа до выполнения запроса + +Статус: принято +Дата: 2026-08-23 + +### Решение + +Формат и тип данных успешного ответа выбираются в `HttpRequestBuilder` до вызова `execute()`: + +```ts +httpClient.get('/users').asJson().execute(); +httpClient.get('/status').asText().execute(); +httpClient.get('/report').asBlob().execute(); +httpClient.get('/archive').asArrayBuffer().execute(); +httpClient.get('/events').asStream().execute(); +``` + +Generic-параметр JSON переносится в `asJson()`. Метод `execute()` не принимает generic и возвращает `IHttpResponse` с типом, выбранным builder. Вызов без селектора остаётся допустимым и возвращает `IHttpResponse`. + +Публичный метод `responseType()` удаляется. Поле `responseType` сохраняется во внутренней конфигурации запроса и в настройках XHR-адаптера. + +Формат ответа `formData` удаляется. Передача `FormData` в `body()` продолжает поддерживаться. + +### Причина + +Выбор формата до выполнения соответствует текущей архитектуре: Fetch- и XHR-адаптеры получают `responseType`, читают тело и возвращают уже декодированный `IHttpResponse`. Такой API не допускает рассинхрон вида `responseType('blob').execute()`, но не требует вводить транспортно-независимую модель непрочитанного ответа. + +Отдельные методы проще условных типов: только JSON требует прикладного generic, а типы текста, бинарных данных и потока известны заранее. `FormData` практически не используется как формат ответа и создавал дополнительную ветку преобразования в XHR. + +### Последствия + +- Все методы настройки builder сохраняют выбранный тип ответа. +- Повторный вызов форматного метода заменяет предыдущий формат и тип. +- `execute()` больше не поддерживается; JSON типизируется через `asJson()`. +- `asText()`, `asBlob()`, `asArrayBuffer()` и `asStream()` не принимают generic. +- Адаптеры продолжают декодировать тело до завершения `execute()`. +- Ошибки декодирования продолжают проходить через существующую модель `ParseError` и response hooks. +- Тип тела `HttpResponseError` не связан с типом успешного ответа и проверяется отдельно согласно D-009. + +### Когда пересматривать + +При появлении пользовательских декодеров, востребованного формата ответа без отдельного метода или необходимости получать непрочитанное тело независимо от транспорта. + +## D-008 — Передача валидатора в `isHttpResponseError` + +Статус: отменено +Дата: 2026-08-25 + +### Решение + +Рассматривалась перегрузка `isHttpResponseError(error, isData)`, которая одновременно проверяла класс ошибки и тело, а также публичный тип `THttpResponseErrorDataGuard`. + +### Причина отмены + +Перегрузка дублирует обычную композицию двух независимых guards, расширяет публичный API и смешивает проверку инфраструктурного класса ошибки с проверкой прикладной схемы данных. После перегрузки `error.data` также сохранял тип `T | undefined`, хотя отдельный guard способен сузить конкретное свойство до `T`. + +### Последствия + +- Параметр `isData` и тип `THttpResponseErrorDataGuard` удалены. +- `isHttpResponseError` снова принимает только проверяемое значение и отвечает только за `instanceof`. +- Принятый вариант композиции зафиксирован в D-009. + +### Когда пересматривать + +При появлении повторяющегося сценария, где требуется передавать целый `HttpResponseError` между слоями после проверки данных. + +## D-009 — Типизация тела HTTP-ошибки композицией guards + +Статус: принято +Дата: 2026-08-25 + +### Решение + +`isHttpResponseError` проверяет только класс ошибки. Схема `error.data` проверяется отдельным пользовательским type guard: + +```ts +try { + await httpClient.post('/users').body(data).asJson().execute(); +} catch (error) { + if (isHttpResponseError(error) && isValidationError(error.data) && (error.status === 400 || error.status === 422)) { + const validation = error.data; // IValidationError + } +} +``` + +### Причина + +Класс ошибки относится к инфраструктуре HTTP-клиента, а схема тела определяется конкретным API. Независимые guards сохраняют эту границу: пакет подтверждает `HttpResponseError`, приложение подтверждает прикладные данные. + +Композиция уже поддерживается TypeScript, не требует нового публичного типа или перегрузки и даёт более точное сужение `error.data` до `T`, исключая `undefined` после успешной проверки. + +### Последствия + +- Адаптеры продолжают создавать `HttpResponseError`. +- Пакет не утверждает тип данных, который не может проверить самостоятельно. +- Приложение может использовать ручной type guard или валидатор схемы из сторонней библиотеки. +- Статус ошибки проверяется независимо от схемы тела. +- Сценарии `400` и `422` покрываются одинаковой композицией guards. + +### Когда пересматривать + +При появлении декларативного контракта API, связывающего endpoint, HTTP-статус и схему ошибки, либо при переходе к типизированному `Result` вместо исключений. + +## D-010 — Отказ от convenience-методов для заголовков + +Статус: принято +Дата: 2026-08-25 + +### Решение + +Пункт P3-2 с методами `json()`, `acceptJson()` и `multipart()` явно не берётся в работу. Эти методы считаются необязательным синтаксическим сахаром поверх существующих `header()` и `headers()`. + +### Причина + +Текущий API уже позволяет явно задать необходимые заголовки без дополнительной абстракции. Метод `json()` легко спутать с `asJson()`, который выбирает формат ответа. `acceptJson()` сокращает настройку одного заголовка, но увеличивает публичную поверхность builder. `multipart()` потенциально провоцирует ручную установку `Content-Type` без корректного boundary, тогда как при передаче `FormData` транспорт должен сформировать этот заголовок самостоятельно. + +### Последствия + +- В `HttpRequestBuilder` не добавляются `json()`, `acceptJson()` и `multipart()`. +- Для `Accept` и пользовательского `Content-Type` используются `header()` или `headers()`. +- `body(FormData)` продолжает передавать данные без автоматической ручной установки multipart-заголовка. +- P3-2 исключён из активного плана и сохранён в `TODO.md` в разделе «Не планируется». + +### Когда пересматривать + +При наличии подтверждённой статистики повторяющихся цепочек настройки заголовков или при проектировании более общего механизма content negotiation. + +## D-011 — Progress callbacks в `XhrAdapter` + +Статус: принято +Дата: 2026-08-25 + +### Решение + +XHR progress настраивается через adapter-wide callbacks в `IXhrAdapterOptions`: + +```ts +new XhrAdapter({ + onDownloadProgress: (event, config) => {}, + onUploadProgress: (event, config) => {} +}); +``` + +Callback получает нативный `ProgressEvent` и `Readonly` фактического запроса. Отдельный публичный тип обработчика не вводится. + +Download callback объединяется с существующим `xhr.onprogress`, который обслуживает `asStream()`. Upload callback назначается на `xhr.upload.onprogress` только при наличии подготовленного тела запроса. + +### Причина + +Upload progress является возможностью XMLHttpRequest и не имеет стандартного аналога в Fetch. Размещение callbacks в конструкторе адаптера соответствует D-001 и не добавляет транспортно-зависимые методы в общий builder. + +Конфигурация запроса передаётся вторым аргументом, поскольку один экземпляр адаптера может одновременно обслуживать несколько запросов. + +### Последствия + +- События передаются с нативной частотой без throttling и вычисления процентов, скорости или оставшегося времени. +- Возвращаемое значение callback игнорируется, а его исключения не преобразуются в `HttpClientError`. +- Download progress доступен для успешных и ошибочных ответов, если браузер отправляет соответствующие события. +- Download callback и `asStream()` работают через один DOM-обработчик; внутренний stream обновляется до вызова пользовательского callback. +- Upload listener не назначается для `GET`, `HEAD`, запросов без тела и адаптеров без `onUploadProgress`. +- Подписка на `XMLHttpRequest.upload` принудительно включает CORS preflight для cross-origin запроса. + +### Когда пересматривать + +При появлении общего progress-контракта для нескольких транспортов, необходимости throttling внутри пакета или стандартной поддержки upload progress в Fetch. + +## D-012 — Scoped-клиент через `withAdapter()` + +Статус: принято +Дата: 2026-08-25 + +### Решение + +`HttpClient.withAdapter(adapter)` создаёт новый `HttpClient` с переданным адаптером, снимком текущих default-настроек и текущими hooks: + +```ts +const uploadClient = httpClient.withAdapter( + new XhrAdapter({ + onUploadProgress: handleUploadProgress + }) +); +``` + +Исходный клиент не изменяется. Headers и params повторно клонируются конструктором нового клиента. Hooks копируются по ссылке как снимок: последующая замена hook в одном клиенте не меняет другой клиент. + +### Причина + +Scoped-клиент позволяет локально выбрать транспорт и его настройки, не добавляя подмену адаптера в `HttpRequestBuilder` или второй terminal-метод вроде `executeWith()`. Механизм применим к любому `IHttpClientAdapter`, а не только к XHR. + +### Последствия + +- `withAdapter()` всегда возвращает новый экземпляр `HttpClient`. +- `baseUrl`, headers, params, timeout, `validateStatus`, credentials и четыре текущих hook наследуются на момент вызова. +- Исходный и scoped-клиенты независимо заменяют hooks через существующие методы. +- Переданный адаптер обязателен и проверяется тем же `assertValidAdapter`, что и настройка конструктора. +- Передача `undefined` не означает возврат к FetchAdapter и считается ошибкой. +- Builder остаётся транспортно-независимым, а `execute()` сохраняет единственный контракт выполнения. + +### Когда пересматривать + +При необходимости менять не только адаптер, но и отдельные defaults одним вызовом, либо при появлении общего API создания дочерних клиентов с частичным override конфигурации. + +## D-013 — Двойная ESM/CommonJS-сборка пакета + +Статус: принято +Дата: 2026-08-25 + +### Решение + +Пакет публикует единый корневой API в двух форматах: + +- ESM: `dist/index.js`; +- CommonJS: `dist/index.cjs`; +- декларации TypeScript: `dist/index.d.ts`. + +Формат выбирается через корневой `exports`-map. Поля `main`, `module` и `types` сохраняются для совместимости с инструментами, которые ещё не используют `exports`. Публичными являются только корневой entry point и `package.json`; deep imports в `dist/*` не входят в контракт. + +JavaScript с целевым стандартом ES2022 собирается Rollup из `src/index.ts`. `tsc` в отдельном `tsconfig.build.json` генерирует временное дерево деклараций, после чего `rollup-plugin-dts` объединяет публичные типы в единый `dist/index.d.ts`. Пакет объявляет `type: "module"`, поэтому CommonJS-файл имеет расширение `.cjs`. + +В `package.json` устанавливаются `sideEffects: false` и `engines.node: ">=20"`. + +### Причина + +ESM-вход позволяет Vite и другим современным сборщикам статически анализировать экспорты и удалять неиспользуемый код. Отдельный CommonJS-вход сохраняет поддержку `require()`. Rollup уже используется в монорепозитории и корректно формирует оба формата без неоднозначности расширений, возникающей при двойном запуске `tsc`. + +Текущие модули пакета не выполняют глобальную регистрацию и не импортируют стили, поэтому декларация `sideEffects: false` соответствует фактическому поведению. + +### Последствия + +- `import` разрешается в ESM-сборку, а `require()` — в CommonJS-сборку. +- Единый declaration-файл не содержит extensionless-импортов и разрешается в TypeScript с `moduleResolution: "Node16"`. +- Неофициальные импорты внутренних файлов блокируются `exports`-map. +- Добавляется post-build проверка форматов, обязательных экспортов и удаления неиспользуемых классов из тестового ESM-бандла. +- Минимальная версия Node.js для серверного выполнения и инструментов разработки пакета — 20. +- Код с побочными эффектами на уровне модуля нельзя добавлять без пересмотра `sideEffects`. + +### Когда пересматривать + +При отказе от CommonJS, добавлении официальных subpath exports, появлении модулей с побочными эффектами или изменении минимальной поддерживаемой версии Node.js. + +## D-014 — Настраиваемая проверка HTTP-статуса + +Статус: принято +Дата: 2026-08-25 + +### Решение + +Успешность HTTP-ответа определяется функцией `validateStatus(status)`. Предикат можно задать в `IHttpClientOptions` для всех запросов клиента или методом `HttpRequestBuilder.validateStatus()` для отдельного запроса. + +Настройки разрешаются в порядке request → client → стандартный диапазон `200–299`. Предикат запроса полностью заменяет клиентский. Чтобы восстановить стандартное поведение для отдельного запроса, диапазон `200–299` указывается явно. + +Fetch- и XHR-адаптеры применяют предикат до разбора тела. Принятый ответ разбирается как успешный в формате, выбранном через `as*()`. Для отклонённого ответа создаётся `HttpResponseError`, а его тело независимо разбирается как JSON или текст. Предикат вызывается один раз на ответ; выброшенная им ошибка без замены передаётся в `onResponseError` и вызывающему коду. + +### Причина + +Не все API используют только `2xx` как прикладной успех. Например, вызывающий код может считать допустимыми `304`, ожидаемый `404` или весь диапазон до `399`. Настройка на двух уровнях позволяет определить общую политику клиента и локально заменить её, не привязывая это решение к Fetch или XHR. + +Проверка выполняется в адаптере, поскольку она должна предшествовать выбору способа разбора тела: успешное тело зависит от `responseType`, а тело `HttpResponseError` разбирается по отдельным правилам. + +### Последствия + +- Публичный тип `TValidateStatus` входит в `IHttpClientOptions` и `IHttpRequestConfig`. +- Невалидное значение обнаруживается при создании клиента, вызове builder или проверке конфигурации после request hooks и приводит к `RequestBuilderError` с кодом `INVALID_VALIDATE_STATUS`. +- `withAdapter()` сохраняет клиентский предикат в scoped-клиенте. +- Пользовательские адаптеры получают `validateStatus` в итоговой конфигурации и отвечают за применение этого контракта, если не делегируют встроенному адаптеру. +- Возвращённое предикатом `true` не проверяет прикладную схему тела и не меняет выбранный `responseType`. + +### Когда пересматривать + +При переносе формирования `HttpResponseError` из адаптеров в общий слой клиента либо при необходимости передавать в предикат весь ответ, а не только числовой статус. diff --git a/services/http-client/README.md b/services/http-client/README.md new file mode 100644 index 00000000..5a65325e --- /dev/null +++ b/services/http-client/README.md @@ -0,0 +1,467 @@ +# @byndyusoft-ui/http-client + +HTTP-клиент с неизменяемым builder, адаптерами Fetch и XMLHttpRequest, типизированными ответами, hooks, отменой и таймаутами. + +## Установка + +```bash +npm install @byndyusoft-ui/http-client +``` + +Пакет требует Node.js 20 или новее для сборки и серверного выполнения. Браузерные приложения получают ESM-вход, CommonJS-потребители используют отдельную CJS-сборку. + +```ts +import { HttpClient } from '@byndyusoft-ui/http-client'; +``` + +```js +const { HttpClient } = require('@byndyusoft-ui/http-client'); +``` + +Публичный API доступен только из корня пакета. Импорты внутренних путей `dist/*` не входят в контракт. Пакет помечен как не имеющий побочных эффектов при импорте, поэтому глобальную регистрацию обработчиков и полифиллов следует выполнять в коде приложения. + +### Поддерживаемые окружения + +- `FetchAdapter` использует глобальные `fetch`, `Headers`, `AbortController` и другие стандартные Fetch API. Они доступны в современных браузерах и Node.js 20. +- `XhrAdapter` предназначен для браузерного окружения и требует `XMLHttpRequest`. Для серверного выполнения нужен совместимый полифилл. +- `asStream()` требует `ReadableStream`; XHR-вариант также использует `TextEncoder`. +- Пакет не устанавливает полифиллы и не изменяет глобальное окружение. + +## Быстрый старт + +По умолчанию используется `FetchAdapter`: + +```ts +import { HttpClient } from '@byndyusoft-ui/http-client'; + +interface IUser { + id: number; + name: string; +} + +const httpClient = new HttpClient({ + baseUrl: 'https://api.example.com' +}); + +const response = await httpClient.get('/users/1').asJson().execute(); +const user = response.data; +``` + +Формат ответа выбирается до выполнения запроса. `execute()` не принимает generic. + +| Метод | Тип `data` | Когда использовать | +| ----------------- | ----------------------------------------- | ---------------------------------- | +| `asJson()` | `T \| undefined` | JSON API | +| `asText()` | `string \| undefined` | текст, HTML, CSV | +| `asBlob()` | `Blob \| undefined` | файлы и бинарные данные в браузере | +| `asArrayBuffer()` | `ArrayBuffer \| undefined` | низкоуровневая бинарная обработка | +| `asStream()` | `ReadableStream \| undefined` | потоковое чтение ответа | + +Вызов без селектора допустим и возвращает `IHttpResponse`. `FetchAdapter` и `XhrAdapter` без собственного `responseType` разбирают такое тело как JSON; настройка `XhrAdapter.responseType` может изменить формат по умолчанию. Generic `asJson()` описывает ожидаемую схему и не проверяет данные во время выполнения. + +Каждый выполненный запрос возвращает `IHttpResponse`: + +| Поле | Тип | Описание | +| ------------ | ------------------------ | ------------------------------------------------------------------------ | +| `data` | `T \| undefined` | декодированное тело; отсутствует для пустого ответа | +| `status` | `number` | HTTP-статус | +| `statusText` | `string` | текст HTTP-статуса | +| `headers` | `Record` | заголовки ответа; стандартные адаптеры приводят имена к нижнему регистру | +| `config` | `IHttpRequestConfig` | фактическая конфигурация, переданная адаптеру | + +## Настройка клиента + +Конструктор принимает `IHttpClientOptions`. Пустой объект создаёт клиент с `FetchAdapter` и без общих настроек: + +```ts +const httpClient = new HttpClient({ + baseUrl: 'https://api.example.com/v1', + headers: { Accept: 'application/json' }, + params: { locale: 'ru' }, + timeout: 10_000, + validateStatus: status => status >= 200 && status < 400, + withCredentials: true +}); +``` + +| Настройка | Назначение | Значение по умолчанию | +| ----------------- | ---------------------------------------------------- | --------------------- | +| `adapter` | транспорт, реализующий `IHttpClientAdapter` | новый `FetchAdapter` | +| `baseUrl` | базовая часть относительных URL | отсутствует | +| `headers` | заголовки всех запросов | отсутствуют | +| `params` | query-параметры всех запросов | отсутствуют | +| `timeout` | таймаут в миллисекундах | не задан на клиенте | +| `validateStatus` | определяет успешность HTTP-статуса | статусы `200–299` | +| `withCredentials` | отправка credentials | зависит от адаптера | +| `onRequest` | преобразование итоговой конфигурации перед адаптером | отсутствует | +| `onRequestError` | восстановление после ошибки request-hook | отсутствует | +| `onResponse` | преобразование успешного ответа | отсутствует | +| `onResponseError` | обработка или восстановление после ошибки ответа | отсутствует | + +## Построение запроса + +Builder неизменяемый: каждый метод возвращает новый экземпляр. + +```ts +const request = httpClient + .post('/users') + .header('X-Request-Id', requestId) + .param('source', 'admin') + .body({ name: 'Jane' }) + .timeout(5_000) + .asJson(); + +const response = await request.execute(); +``` + +Поддерживаются методы `GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `OPTIONS` и `PATCH`. Для `GET` и `HEAD` тело запрещено. + +| Метод builder | Назначение | +| --------------------------------- | ------------------------------------------------------------ | +| `baseUrl(value)` | переопределяет базовый URL | +| `header(name, value)` | добавляет или заменяет один заголовок без учёта регистра | +| `headers(values)` | объединяет несколько заголовков | +| `param(name, value)` | добавляет, заменяет или удаляет один query-параметр | +| `params(values)` | объединяет несколько query-параметров | +| `body(data)` | задаёт тело запроса | +| `signal(signal)` | привязывает пользовательский `AbortSignal` | +| `timeout(milliseconds)` | задаёт таймаут; `0` отключает также унаследованный таймаут | +| `validateStatus(predicate)` | определяет успешность статуса конкретного ответа | +| `withCredentials(value)` | управляет credentials конкретного запроса | +| `bearer(token)` | устанавливает `Authorization: Bearer ` | +| `asJson()` и остальные `as*()` | выбирают формат чтения и тип успешного ответа | +| `build()` | возвращает независимый снимок `Readonly` | +| `execute()` | проверяет конфигурацию и выполняет запрос | + +## URL, заголовки и query-параметры + +Относительный URL объединяется с `baseUrl` как путь: завершающий slash базового URL и начальный slash запроса не дублируются. Абсолютный URL запроса используется без `baseUrl`. Query-параметры базового URL и запроса сохраняются, а fragment берётся из URL запроса. + +```ts +const response = await httpClient + .get('/users?sort=name#list') + .params({ page: 2, active: true, role: ['admin', 'editor'] }) + .execute(); +``` + +Значения query-параметров могут быть строками, числами, boolean или массивами этих значений. Массив сериализуется повторяющимися ключами. `null` и `undefined` удаляют ранее накопленный ключ в одной карте или цепочке builder, а такие элементы массива пропускаются. Непустые параметры запроса имеют приоритет над параметрами клиента. Значение `null` или `undefined` из запроса нормализуется до объединения и поэтому не удаляет одноимённый параметр, заданный в `HttpClient`. + +Заголовки также объединяются слева направо, но их имена сравниваются без учёта регистра. Последнее значение заменяет предыдущее и сохраняет написание последнего имени. + +## Тело запроса + +`body()` принимает JSON-совместимые значения и готовые транспортные тела: + +| Значение | Преобразование и `Content-Type` | +| ------------------------------------------ | ------------------------------------------------------------------------------------- | +| объект, массив, number, boolean или `null` | JSON; при отсутствии заголовка добавляется `application/json` | +| string | отправляется без изменений; заголовок автоматически не добавляется | +| `URLSearchParams` | строка form-urlencoded; добавляется `application/x-www-form-urlencoded;charset=UTF-8` | +| `FormData` | отправляется без изменений; boundary формирует транспорт | +| `Blob`, `ArrayBuffer`, `ArrayBufferView` | отправляется без изменений | + +Пользовательский `Content-Type` никогда не заменяется автоматически. Для `FormData` его не следует устанавливать вручную, иначе в заголовке может отсутствовать корректный boundary. + +`body(undefined)` синхронно выбрасывает `RequestBuilderError`. Тело также запрещено для `GET` и `HEAD`. Ошибка `JSON.stringify`, например циклическая ссылка или несериализуемое значение, преобразуется адаптером в `RequestPreparationError` с исходной причиной в `cause`. + +## Проверка HTTP-статуса + +По умолчанию Fetch- и XHR-адаптеры считают успешными статусы от `200` до `299`. Настройка `validateStatus` позволяет изменить это правило для всего клиента или одного запроса: + +```ts +const httpClient = new HttpClient({ + baseUrl: 'https://api.example.com', + validateStatus: status => status >= 200 && status < 400 +}); + +const response = await httpClient + .get('/users/42') + .validateStatus(status => status === 200 || status === 404) + .asJson() + .execute(); +``` + +Предикат запроса заменяет предикат клиента. Чтобы для отдельного запроса вернуть стандартное поведение поверх клиентской настройки, его нужно задать явно: + +```ts +const response = await httpClient + .get('/health') + .validateStatus(status => status >= 200 && status < 300) + .execute(); +``` + +Предикат вызывается один раз с числовым статусом ответа. Если он возвращает `true`, тело разбирается в выбранном через `as*()` формате и ответ проходит через `onResponse`. Если он возвращает `false`, адаптер создаёт `HttpResponseError`, а тело ошибки пытается разобрать как JSON или текст. Исключение из предиката передаётся без замены в `onResponseError` и вызывающему коду. + +## Отмена и таймауты + +```ts +import { isAbortError } from '@byndyusoft-ui/http-client'; + +const controller = new AbortController(); +const request = httpClient.get('/report').signal(controller.signal).timeout(5_000).asBlob().execute(); + +controller.abort('Navigation changed'); + +try { + await request; +} catch (error) { + if (!isAbortError(error)) { + throw error; + } +} +``` + +`timeout()` принимает конечное неотрицательное число миллисекунд. Значение `0` отключает таймаут, включая заданный в `HttpClient` или `XhrAdapter`. Пользовательская отмена приводит к `AbortError`, истечение таймаута — к `TimeoutError` с фактическим значением в поле `timeout`. + +## Адаптеры + +### Fetch + +```ts +import { FetchAdapter, HttpClient } from '@byndyusoft-ui/http-client'; + +const httpClient = new HttpClient({ + adapter: new FetchAdapter({ + cache: 'no-store', + mode: 'cors', + redirect: 'follow' + }) +}); +``` + +Fetch-специфичные параметры задаются в конструкторе адаптера: + +| Настройка | Назначение | +| ---------------- | ------------------------------------------------- | +| `cache` | режим браузерного HTTP-кэша | +| `credentials` | базовый режим `omit`, `same-origin` или `include` | +| `integrity` | Subresource Integrity | +| `keepalive` | разрешает запросу пережить закрытие страницы | +| `mode` | `cors`, `no-cors` или `same-origin` | +| `redirect` | `follow`, `error` или `manual` | +| `referrer` | значение referrer | +| `referrerPolicy` | политика передачи referrer | + +`mode: 'navigate'` запрещён для программного Fetch. `cache: 'only-if-cached'` допустим только вместе с `mode: 'same-origin'`. При `redirect: 'manual'` браузер может вернуть `opaque-redirect` со статусом `0`; продолжить такой редирект вручную нельзя. + +### XMLHttpRequest + +```ts +import { HttpClient, XhrAdapter } from '@byndyusoft-ui/http-client'; + +const httpClient = new HttpClient({ + adapter: new XhrAdapter({ + mimeType: 'application/json', + timeout: 10_000, + withCredentials: true, + onDownloadProgress: (event, config) => { + console.log(config.url, event.loaded, event.total); + } + }) +}); +``` + +XHR полезен для сценариев, которым нужны возможности `XMLHttpRequest`. `onDownloadProgress` и `onUploadProgress` получают нативный `ProgressEvent` и итоговую конфигурацию запроса. События передаются без throttling; если `lengthComputable === false`, значение `total` нельзя считать достоверным. + +| Настройка | Назначение | +| -------------------- | --------------------------------------------------- | +| `mimeType` | переопределяет MIME type через `overrideMimeType()` | +| `responseType` | формат ответа по умолчанию для запросов без `as*()` | +| `timeout` | таймаут по умолчанию | +| `withCredentials` | credentials по умолчанию | +| `onDownloadProgress` | события загрузки ответа | +| `onUploadProgress` | события отправки тела | + +Обработчики задаются на весь экземпляр адаптера. Для изолированной загрузки можно создать scoped-клиент: + +```ts +interface IUploadResult { + id: string; +} + +const uploadClient = httpClient.withAdapter( + new XhrAdapter({ + onUploadProgress: (event, config) => { + if (event.lengthComputable) { + console.log(config.url, event.loaded / event.total); + } + } + }) +); + +await uploadClient.post('/files').body(file).asJson().execute(); +``` + +`withAdapter()` возвращает новый клиент со снимком текущих defaults и hooks. Исходный клиент и его адаптер не изменяются; последующая замена hooks в одном клиенте не влияет на другой. + +Обработчик upload подключается только при наличии `onUploadProgress` и фактического тела запроса. Для cross-origin запроса такая подписка принудительно включает CORS preflight согласно [спецификации XMLHttpRequest](), поэтому сервер должен корректно обрабатывать `OPTIONS`. `FetchAdapter` не предоставляет стандартный upload progress. + +Текущий `asStream()` для XHR не является настоящим сетевым стримом: полученный текст накапливается в памяти. Прогресс загрузки и stream могут использоваться одновременно. + +### Пользовательский адаптер + +Транспорт можно реализовать самостоятельно через `IHttpClientAdapter`. Адаптер получает полностью объединённый `IHttpRequestConfig` и должен вернуть `IHttpResponse` либо выбросить подходящую ошибку: + +```ts +import { + FetchAdapter, + HttpClient, + type IHttpClientAdapter, + type IHttpRequestConfig, + type IHttpResponse +} from '@byndyusoft-ui/http-client'; + +class LoggingAdapter implements IHttpClientAdapter { + public constructor(private readonly inner = new FetchAdapter()) {} + + public request(config: IHttpRequestConfig): Promise> { + console.log(config.method, config.url); + + return this.inner.request(config); + } +} + +const httpClient = new HttpClient({ adapter: new LoggingAdapter() }); +``` + +`validateStatus` входит в конфигурацию запроса и исполняется транспортом. Пользовательский адаптер, который не делегирует выполнение Fetch- или XHR-адаптеру, должен самостоятельно применить предикат и сформировать `HttpResponseError` для отклонённого статуса. + +## Приоритет конфигурации + +Общие настройки разрешаются в следующем порядке: + +1. Настройки конкретного запроса в builder. +2. Настройки `HttpClient`. +3. Значения по умолчанию адаптера. + +Предикат `validateStatus` запроса имеет приоритет над предикатом клиента; при отсутствии обоих используется диапазон `200–299`. Для XHR общий порядок применяется также к `timeout`, `responseType` и `withCredentials`. Заголовки, params и `baseUrl` не имеют значений по умолчанию на уровне адаптера. Специфичные для Fetch и XHR параметры задаются только в конструкторах соответствующих адаптеров. + +## Credentials и CORS + +```ts +const response = await httpClient.get('/profile').withCredentials(true).asJson().execute(); +``` + +Для Fetch `withCredentials` преобразуется в `credentials`: + +- `true` → `include`; +- `false` → `same-origin`; +- отсутствие значения → `credentials` адаптера или `same-origin`. + +Для XHR `true` устанавливает `xhr.withCredentials = true`, а `false` — `false`. При отсутствии значения используется настройка `XhrAdapter` или `false`. + +Клиент не может самостоятельно разрешить CORS. Сервер должен возвращать подходящие `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers` и, для credentialed-запросов, `Access-Control-Allow-Credentials: true`. Cookie также подчиняются правилам `SameSite` и `Secure` браузера. + +## Ошибки + +Все ошибки пакета наследуются от `HttpClientError`. Базовый класс содержит `message`, стандартное поле `cause` и, если запрос уже был сформирован, `config`. + +| Ошибка | Причина | Дополнительные поля | +| ------------------------- | ------------------------------------------ | ----------------------------------------- | +| `RequestBuilderError` | некорректные настройки builder | `code` | +| `RequestPreparationError` | не удалось подготовить транспортный запрос | `cause`, `config` | +| `HttpResponseError` | статус отклонён функцией `validateStatus` | `status`, `statusText`, `headers`, `data` | +| `ParseError` | не удалось декодировать успешный ответ | `responseType`, `raw`, `cause`, `config` | +| `NetworkError` | сетевая ошибка | `cause`, `config` | +| `AbortError` | запрос отменён через `AbortSignal` | `cause`, `config` | +| `TimeoutError` | истёк таймаут | `timeout`, `cause`, `config` | + +Для каждого класса экспортируется guard: `isHttpClientError`, `isRequestBuilderError`, `isRequestPreparationError`, `isHttpResponseError`, `isParseError`, `isNetworkError`, `isAbortError` и `isTimeoutError`. Они используют `instanceof`; `isHttpClientError()` позволяет одним условием обработать любую ошибку пакета. + +Коды ошибок builder экспортируются в `REQUEST_BUILDER_ERROR_CODES` и доступны через `RequestBuilderError.code`. + +При отклонённом статусе адаптер пытается разобрать тело как JSON, затем как текст. Пустое или недоступное тело даёт `data === undefined`. Тип данных ошибки не связан с типом успешного ответа и проверяется отдельно: + +```ts +import { isHttpResponseError } from '@byndyusoft-ui/http-client'; + +interface IValidationError { + message: string; + errors: Record; +} + +function isValidationError(data: unknown): data is IValidationError { + if (typeof data !== 'object' || data === null) { + return false; + } + + const candidate = data as Partial; + + if ( + typeof candidate.message !== 'string' || + typeof candidate.errors !== 'object' || + candidate.errors === null || + Array.isArray(candidate.errors) + ) { + return false; + } + + return Object.values(candidate.errors).every( + value => Array.isArray(value) && value.every(item => typeof item === 'string') + ); +} + +try { + await httpClient.get('/users/1').asJson().execute(); +} catch (error) { + if (isHttpResponseError(error) && isValidationError(error.data) && (error.status === 400 || error.status === 422)) { + console.error(error.data.message, error.data.errors); + } +} +``` + +`isHttpResponseError(error)` проверяет класс ошибки и открывает доступ к `data` типа `unknown`. Схема тела принадлежит конкретному API, поэтому проверяется отдельным пользовательским type guard. После обеих проверок `error.data` имеет точный тип `IValidationError` без `undefined`. + +## Hooks + +Hooks можно передать в конструктор или назначить методами клиента. Цепочка выполняется в следующем порядке: + +1. Настройки клиента и builder объединяются. +2. `onRequest` получает итоговую конфигурацию и обязан вернуть конфигурацию для продолжения. +3. Если `onRequest` выбрасывает ошибку или возвращает невалидную конфигурацию, вызывается `onRequestError`. Возвращённая конфигурация восстанавливает запрос; `undefined` повторно выбрасывает исходную ошибку. +4. Адаптер выполняет запрос. +5. Успешный ответ проходит через `onResponse`, а его возвращаемое значение передаётся вызывающему коду. +6. Ошибка адаптера или `onResponse` передаётся в `onResponseError`. Возвращённый ответ восстанавливает выполнение; `undefined` повторно выбрасывает исходную ошибку. Восстановленный ответ повторно через `onResponse` не проходит. + +Request-ошибки, возникшие до вызова адаптера, не передаются в response hooks. + +```ts +const httpClient = new HttpClient({ + onRequest: config => ({ + ...config, + headers: { ...config.headers, Authorization: `Bearer ${token}` } + }), + onResponse: response => response, + onResponseError: error => { + throw error; + } +}); +``` + +Поддерживаются `onRequest`, `onRequestError`, `onResponse` и `onResponseError`. Повторное назначение hook заменяет предыдущее значение. + +```ts +httpClient + .onRequest(addAuthorization) + .onRequestError(recoverRequest) + .onResponse(normalizeResponse) + .onResponseError(recoverResponse); +``` + +Методы возвращают тот же клиент для построения цепочки вызовов. `withAdapter()` копирует ссылки на текущие hooks в новый клиент; последующая замена hook в одном экземпляре не влияет на другой. + +## Публичные типы и константы + +Основные типы доступны из корня пакета: `IHttpClientOptions`, `IHttpClientAdapter`, `IHttpRequestConfig`, `IHttpResponse`, `IFetchAdapterOptions`, `IXhrAdapterOptions`, `THttpHeaders`, `THttpParams`, `THttpRequestBody`, `THttpMethod`, `THttpResponseType`, `TValidateStatus`, типы hooks и опций ошибок. + +Также экспортируются `HTTP_METHODS`, `HTTP_STATUS_CODES`, `HTTP_RESPONSE_TYPES` и `REQUEST_BUILDER_ERROR_CODES`. Внутренние asserts и utilities не входят в корневой публичный API. + +## Ограничения текущей версии + +- Встроенных повторных запросов нет; retry должен выполняться отдельным слоем оркестрации. +- Для каждой фазы хранится только один hook, а повторное назначение заменяет предыдущий. +- `asJson()` задаёт ожидаемый TypeScript-тип, но не проверяет схему данных во время выполнения. +- Формат ответа `FormData` не поддерживается; `FormData` можно использовать только как тело запроса. +- Fetch stream является нативным потоком. После возврата `ReadableStream` таймаут Fetch больше не контролирует его чтение. +- XHR stream формируется из накопленного `responseText`, поэтому весь текст остаётся в памяти. Promise успешного stream-запроса может разрешиться после получения заголовков, а последующая сетевая ошибка, abort или timeout передаётся через ошибку самого потока. Этот пограничный сценарий пока считается экспериментальным и может быть уточнён до стабильной версии. +- Поведение CORS с credentials, cookies, redirects и `keepalive` зависит от браузера и должно проверяться интеграционно в целевом окружении. diff --git a/services/http-client/TODO.md b/services/http-client/TODO.md new file mode 100644 index 00000000..0feb379a --- /dev/null +++ b/services/http-client/TODO.md @@ -0,0 +1,70 @@ +# TODO — @byndyusoft-ui/http-client + +Связанные архитектурные решения зафиксированы в [DECISIONS.md](./DECISIONS.md). + +## P2 + +### P2-4 — Спроектировать opt-in retry + +Согласно D-005, повторы будут реализованы в отдельном opt-in классе для оркестрации запросов, без встраивания retry-логики в `HttpClient`, адаптеры или hooks. Нужно спроектировать лимит попыток, exponential backoff, поддержку `Retry-After`, отмену через `AbortSignal`, перечень временных сетевых сбоев и статусов (`408`, `429`, часть `5xx`), а также безопасное поведение для мутаций. + +### P2-6 — Добавить браузерные интеграционные тесты + +Проверить в настоящих браузерах credentialed CORS, preflight, cookies с `SameSite`/`Secure`, redirects и `keepalive`. Тесты jsdom и mock-адаптеров не воспроизводят эти особенности платформы. Актуально после появления реальных пользователей credentials-API. + +## P3 + +### P3-1 — Развить hooks до композиционного pipeline + +Сейчас на каждую фазу можно назначить лишь один хук, а следующий вызов заменяет предыдущий. При совместном использовании auth, tracing и логирования потребуется композиция хуков с предсказуемым порядком и правилами восстановления. + +## Рекомендуемый порядок + +1. P2: retry по продуктовым нуждам +2. P2–P3: DX и полировка фоном + +## Отложено + +### P2-5 — Зафиксировать edge-cases XHR stream + +Сценарий отложен как крайне редкий. Текущее ограничение явно описано в README: XHR stream формируется из `responseText`, держит весь текст ответа в памяти, а поздние abort, timeout и network errors передаются через ошибку потока после завершения `execute()`. + +Вернуться к задаче следует при появлении реального production-сценария XHR stream, требований к большим или бинарным ответам либо необходимости унифицировать поведение Fetch и XHR для timeout, abort и `ReadableStream.cancel()`. Тогда контракт нужно закрепить тестами для поздних ошибок, ответов 4xx, отмены потока и hooks. + +## Не планируется + +### P3-2 — Convenience-методы builder + +Явно не берём в работу как необязательный синтаксический сахар. Для настройки заголовков достаточно `header()` и `headers()`, а решение можно пересмотреть только при появлении подтверждённых повторяющихся сценариев. Причины зафиксированы в D-010. + +## Закрыто + +1. Модель ошибок + guards (`HttpResponseError`, `ParseError`, `cause`/`config`) +2. `TimeoutError.timeout`, `ParseError.responseType` / `raw` +3. Body: FormData / URLSearchParams / Blob / ArrayBufferView (`prepareRequestBody`) +4. Default `params`, number/boolean params, null-skip +5. Options/config validation, `RequestPreparationError` +6. `stream` response type +7. `FetchAdapter`: `ParseError`, если streaming body отсутствует (`response.body === null`) +8. `HttpResponseError` передаёт `config` в базовый `HttpClientError` +9. Default `FetchAdapter`, `withCredentials` (client / builder / оба адаптера) +10. Adapter options (`FetchAdapterOptions`, `XhrAdapterOptions`) +11. Удалены устаревшие закомментированные методы из `HttpRequestBuilder` +12. Publish hygiene: публикация ограничена `files: ["dist"]`, избыточный `.npmignore` удалён. +13. Default `headers` клонируются в конструкторе `HttpClient`. +14. Языковое соглашение: комментарии и JSDoc исходников — английский; пользовательские документы пакета — русский (D-002). +15. Валидация `FetchAdapter`: исключён `mode: 'navigate'`; `cache: 'only-if-cached'` требует `mode: 'same-origin'`. +16. Parity Fetch/XHR: `Content-Length: 0` и error body при `blob`. +17. Пустые `params` нормализуются в `undefined` вместо `{}`. +18. Утилиты покрыты изолированными тестами и английскими JSDoc; зафиксирована мутация headers в `prepareRequestBody`. +19. Согласно D-006, `body(undefined)` выбрасывает `RequestBuilderError` с кодом `INVALID_BODY`, а `body(null)` отправляет JSON `null`. +20. Согласно D-007, формат ответа выбирается через `asJson()`, `asText()`, `asBlob()`, `asArrayBuffer()` или `asStream()`, а `execute()` не принимает generic. +21. Формат ответа `formData` удалён; отправка `FormData` через `body()` сохранена. +22. Согласно D-009, тело `HttpResponseError` типизируется композицией `isHttpResponseError(error) && isData(error.data)` без усложнения публичного guard; сценарии `400` и `422` покрыты type- и runtime-тестами. +23. Согласно D-011, `XhrAdapter` поддерживает adapter-wide callbacks `onDownloadProgress` и `onUploadProgress`; upload listener подключается только для запросов с телом. +24. Согласно D-012, `HttpClient.withAdapter()` создаёт независимый scoped-клиент со снимком текущих defaults и hooks. +25. P3-3: команда запуска тестов отдельного workspace в корневом `AGENTS.md` исправлена на `npm test -w `. +26. Согласно D-013, пакет публикует ESM и CommonJS через `exports`, поддерживает tree-shaking, помечен `sideEffects: false` и требует Node.js 20 или новее. +27. P0-1: minor changeset применён, версия пакета повышена до `0.1.0`, создан русскоязычный `CHANGELOG.md`; публикация в npm не выполнялась. +28. README дополнен полным пользовательским контрактом: окружения, client/builder API, response, URL/params/body, timeout/abort, настройки адаптеров, custom adapter, ошибки, hooks, публичные типы и ограничения. +29. Согласно D-014, `validateStatus` поддерживается на уровне клиента и запроса с приоритетом request → client → стандартный диапазон `200–299`; контракт реализован одинаково в Fetch и XHR. diff --git a/services/http-client/package.json b/services/http-client/package.json new file mode 100644 index 00000000..8de7e42c --- /dev/null +++ b/services/http-client/package.json @@ -0,0 +1,52 @@ +{ + "name": "@byndyusoft-ui/http-client", + "version": "0.1.0", + "description": "Byndyusoft UI HTTP Client Service", + "keywords": [ + "byndyusoft", + "byndyusoft-ui", + "http-client" + ], + "author": "Byndyusoft Frontend Developer ", + "homepage": "https://github.com/Byndyusoft/ui/tree/master/services/http-client#readme", + "license": "Apache-2.0", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist" + ], + "engines": { + "node": ">=20" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Byndyusoft/ui.git" + }, + "scripts": { + "build": "rimraf dist && tsc --project tsconfig.build.json && rollup --config && rimraf dist/declarations && npm run test:package", + "clean": "rimraf dist && rimraf .turbo && rimraf node_modules && rimraf package-lock.json", + "lint": "eslint src --config ../../eslint.config.js", + "test": "vitest run --config vitest.config.mjs --typecheck", + "test:package": "node scripts/verify-package.mjs" + }, + "bugs": { + "url": "https://github.com/Byndyusoft/ui/issues" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "msw": "^2.4.9" + } +} diff --git a/services/http-client/rollup.config.mjs b/services/http-client/rollup.config.mjs new file mode 100644 index 00000000..01e6c4d1 --- /dev/null +++ b/services/http-client/rollup.config.mjs @@ -0,0 +1,38 @@ +import typescript from '@rollup/plugin-typescript'; +import autoExternal from 'rollup-plugin-auto-external'; +import { dts } from 'rollup-plugin-dts'; + +const javascriptConfig = { + input: 'src/index.ts', + output: [ + { + file: 'dist/index.js', + format: 'esm' + }, + { + exports: 'named', + file: 'dist/index.cjs', + format: 'cjs' + } + ], + plugins: [ + autoExternal(), + typescript({ + declaration: false, + include: ['src/**/*.ts'], + target: 'ES2022', + tsconfig: './tsconfig.json' + }) + ] +}; + +const declarationsConfig = { + input: 'dist/declarations/index.d.ts', + output: { + file: 'dist/index.d.ts', + format: 'es' + }, + plugins: [dts()] +}; + +export default [javascriptConfig, declarationsConfig]; diff --git a/services/http-client/scripts/verify-package.mjs b/services/http-client/scripts/verify-package.mjs new file mode 100644 index 00000000..6121e0f2 --- /dev/null +++ b/services/http-client/scripts/verify-package.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { rollup } from 'rollup'; +import typescript from 'typescript'; + +const packageName = '@byndyusoft-ui/http-client'; +const requiredExports = ['FetchAdapter', 'HTTP_METHODS', 'HttpClient', 'XhrAdapter']; +const require = createRequire(import.meta.url); + +const esmPackage = await import(packageName); +const cjsPackage = require(packageName); +const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); +const declarations = await readFile(new URL('../dist/index.d.ts', import.meta.url), 'utf8'); + +assert.match(require.resolve(packageName), /\/dist\/index\.cjs$/); +assert.deepEqual(packageJson.exports['.'], { + types: './dist/index.d.ts', + import: './dist/index.js', + require: './dist/index.cjs' +}); +assert.equal(packageJson.engines.node, '>=20'); +assert.equal(packageJson.sideEffects, false); +assert.equal(packageJson.type, 'module'); +assert.doesNotMatch(declarations, /from\s+['"]\.\//); + +await assert.rejects(import(`${packageName}/dist/index.js`), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); +assert.throws(() => require(`${packageName}/dist/index.cjs`), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); + +for (const exportName of requiredExports) { + assert.notEqual(esmPackage[exportName], undefined, `Missing ${exportName} in the ESM entry`); + assert.notEqual(cjsPackage[exportName], undefined, `Missing ${exportName} in the CommonJS entry`); +} + +const typeResolution = typescript.resolveModuleName( + packageName, + fileURLToPath(import.meta.url), + { + module: typescript.ModuleKind.Node16, + moduleResolution: typescript.ModuleResolutionKind.Node16 + }, + typescript.sys +).resolvedModule; + +assert.notEqual(typeResolution, undefined, 'TypeScript cannot resolve the package declarations'); +assert.match(typeResolution.resolvedFileName, /\/dist\/index\.d\.ts$/); + +const esmEntryPath = fileURLToPath(new URL('../dist/index.js', import.meta.url)); +const virtualEntryId = '\0http-client-tree-shaking-check'; +const bundle = await rollup({ + input: virtualEntryId, + plugins: [ + { + load(id) { + if (id === virtualEntryId) { + return `export { HTTP_METHODS } from ${JSON.stringify(esmEntryPath)};`; + } + + return null; + }, + name: 'http-client-tree-shaking-check', + resolveId(id) { + if (id === virtualEntryId) { + return id; + } + + return null; + } + } + ] +}); +const { output } = await bundle.generate({ format: 'esm' }); +const generatedCode = output.map(chunk => ('code' in chunk ? chunk.code : '')).join('\n'); + +assert.match(generatedCode, /HTTP_METHODS/); +assert.doesNotMatch(generatedCode, /class HttpClient/); +assert.doesNotMatch(generatedCode, /class FetchAdapter/); +assert.doesNotMatch(generatedCode, /class XhrAdapter/); + +await bundle.close(); diff --git a/services/http-client/src/adapters/FetchAdapter.ts b/services/http-client/src/adapters/FetchAdapter.ts new file mode 100644 index 00000000..b59bfae9 --- /dev/null +++ b/services/http-client/src/adapters/FetchAdapter.ts @@ -0,0 +1,265 @@ +import { HTTP_METHODS, HTTP_RESPONSE_TYPES } from '../constants'; +import { HttpClientError } from '../errors/HttpClientError'; +import { HttpResponseError } from '../errors/HttpResponseError'; +import { NetworkError } from '../errors/NetworkError'; +import { ParseError } from '../errors/ParseError'; +import { RequestPreparationError } from '../errors/RequestPreparationError'; +import { TimeoutError } from '../errors/TimeoutError'; +import { AbortError } from '../errors/AbortError'; +import { + IFetchAdapterOptions, + IHttpClientAdapter, + IHttpRequestConfig, + IHttpResponse, + THttpHeaders, + THttpResponseType +} from '../types'; +import { assertValidFetchAdapterOptions } from '../asserts'; +import { buildUrl, getErrorMessage, isStatusAccepted, mergeHeaders, prepareRequestBody } from '../utilities'; + +function extractResponseHeaders(headers: Headers): THttpHeaders { + const result: THttpHeaders = {}; + headers.forEach((value, key) => { + result[key] = value; + }); + return result; +} + +interface IPreparedFetchRequest { + readonly fullUrl: string; + readonly requestHeaders: THttpHeaders; + readonly body: BodyInit | undefined; + readonly signal: AbortSignal | undefined; + readonly userSignal: AbortSignal | undefined; + readonly timeout: number | undefined; + cleanup(): void; +} + +function prepareFetchRequest(config: IHttpRequestConfig): IPreparedFetchRequest { + const { url, method, headers = {}, params, data, signal: userSignal, timeout, baseUrl: baseURL } = config; + let timeoutId: ReturnType | undefined; + let removeAbortListener: (() => void) | undefined; + const cleanup = (): void => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + + removeAbortListener?.(); + }; + + try { + const fullUrl = buildUrl(baseURL, url, params); + const requestHeaders = mergeHeaders(headers); + const body = + data !== undefined && method !== HTTP_METHODS.GET && method !== HTTP_METHODS.HEAD + ? prepareRequestBody(data, requestHeaders) + : undefined; + + if (timeout) { + const controller = new AbortController(); + timeoutId = setTimeout(() => controller.abort(), timeout); + + if (userSignal) { + if (userSignal.aborted) { + throw new AbortError('Request was aborted', { cause: userSignal.reason, config }); + } + + const abort = (): void => controller.abort(); + userSignal.addEventListener('abort', abort, { once: true }); + removeAbortListener = () => userSignal.removeEventListener('abort', abort); + } + + return { + fullUrl, + requestHeaders, + body, + signal: controller.signal, + userSignal, + timeout, + cleanup + }; + } + + return { + fullUrl, + requestHeaders, + body, + signal: userSignal, + userSignal, + timeout, + cleanup + }; + } catch (error) { + cleanup(); + throw error; + } +} + +function resolveCredentials( + withCredentials: boolean | undefined, + adapterCredentials: RequestCredentials | undefined +): RequestCredentials { + if (withCredentials === true) { + return 'include'; + } + + if (withCredentials === false) { + return 'same-origin'; + } + + return adapterCredentials ?? 'same-origin'; +} + +function createFetchError(error: unknown, request: IPreparedFetchRequest, config: IHttpRequestConfig): HttpClientError { + if (error instanceof HttpClientError) { + return error; + } + + if (request.userSignal?.aborted) { + return new AbortError('Request was aborted', { cause: request.userSignal.reason ?? error, config }); + } + + if (request.timeout && error instanceof DOMException && error.name === 'AbortError') { + return new TimeoutError(`Request timed out after ${request.timeout}ms`, { + cause: error, + config, + timeout: request.timeout + }); + } + + return new NetworkError(getErrorMessage(error, 'Network request failed'), { cause: error, config }); +} + +async function parseResponseBody( + response: Response, + config: IHttpRequestConfig, + responseType?: THttpResponseType +): Promise { + if (response.status === 204 || response.headers.get('content-length') === '0') { + return undefined as T; + } + + switch (responseType) { + case HTTP_RESPONSE_TYPES.TEXT: + return response.text() as Promise; + case HTTP_RESPONSE_TYPES.BLOB: + return response.blob() as Promise; + case HTTP_RESPONSE_TYPES.ARRAY_BUFFER: + return response.arrayBuffer() as Promise; + case HTTP_RESPONSE_TYPES.STREAM: + if (response.body === null) { + throw new ParseError('Streaming response body is not available', { + config, + responseType: HTTP_RESPONSE_TYPES.STREAM + }); + } + + return response.body as T; + case HTTP_RESPONSE_TYPES.JSON: + default: { + const resolvedResponseType = responseType ?? HTTP_RESPONSE_TYPES.JSON; + const text = await response.text(); + if (!text) { + return undefined as T; + } + try { + return JSON.parse(text) as T; + } catch (error) { + throw new ParseError('Failed to parse response body as JSON', { + cause: error, + config, + responseType: resolvedResponseType, + raw: text + }); + } + } + } +} + +export class FetchAdapter implements IHttpClientAdapter { + private readonly options: IFetchAdapterOptions; + + constructor(options: IFetchAdapterOptions = {}) { + assertValidFetchAdapterOptions(options); + + this.options = { ...options }; + } + + async request(config: IHttpRequestConfig): Promise> { + let request: IPreparedFetchRequest; + + try { + request = prepareFetchRequest(config); + } catch (error) { + if (error instanceof HttpClientError) { + throw error; + } + + throw new RequestPreparationError('Failed to prepare HTTP request', { cause: error, config }); + } + + let response: Response; + + try { + response = await fetch(request.fullUrl, { + ...this.options, + method: config.method, + headers: request.requestHeaders, + body: request.body, + credentials: resolveCredentials(config.withCredentials, this.options.credentials), + signal: request.signal + }); + } catch (error) { + request.cleanup(); + throw createFetchError(error, request, config); + } + + let statusAccepted: boolean; + + try { + statusAccepted = isStatusAccepted(response.status, config.validateStatus); + } catch (error) { + request.cleanup(); + throw error; + } + + try { + if (!statusAccepted) { + let errorData: unknown; + try { + const text = await response.text(); + try { + errorData = JSON.parse(text); + } catch { + errorData = text || undefined; + } + } catch { + errorData = undefined; + } + + throw new HttpResponseError( + `Request failed with status code ${response.status}`, + response.status, + response.statusText, + extractResponseHeaders(response.headers), + config, + errorData + ); + } + + const responseData = await parseResponseBody(response, config, config.responseType); + + return { + data: responseData, + status: response.status, + statusText: response.statusText, + headers: extractResponseHeaders(response.headers), + config + }; + } catch (error) { + throw createFetchError(error, request, config); + } finally { + request.cleanup(); + } + } +} diff --git a/services/http-client/src/adapters/XhrAdapter.ts b/services/http-client/src/adapters/XhrAdapter.ts new file mode 100644 index 00000000..2dc9d344 --- /dev/null +++ b/services/http-client/src/adapters/XhrAdapter.ts @@ -0,0 +1,451 @@ +import { HTTP_METHODS, HTTP_RESPONSE_TYPES } from '../constants'; +import { HttpClientError } from '../errors/HttpClientError'; +import { HttpResponseError } from '../errors/HttpResponseError'; +import { NetworkError } from '../errors/NetworkError'; +import { ParseError } from '../errors/ParseError'; +import { RequestPreparationError } from '../errors/RequestPreparationError'; +import { TimeoutError } from '../errors/TimeoutError'; +import { AbortError } from '../errors/AbortError'; +import { + IHttpClientAdapter, + IHttpRequestConfig, + IHttpResponse, + IXhrAdapterOptions, + THttpHeaders, + THttpResponseType +} from '../types'; +import { assertValidXhrAdapterOptions } from '../asserts'; +import { buildUrl, getErrorMessage, isStatusAccepted, mergeHeaders, prepareRequestBody } from '../utilities'; + +function parseResponseHeaders(rawHeaders: string): THttpHeaders { + const result: THttpHeaders = {}; + const lines = rawHeaders.trim().split('\r\n'); + for (const line of lines) { + const index = line.indexOf(': '); + if (index > 0) { + const key = line.slice(0, index).toLowerCase(); + const value = line.slice(index + 2); + result[key] = value; + } + } + return result; +} + +interface IXhrResponseStream { + readonly data: ReadableStream; + append(): void; + close(): void; + error(reason: unknown): void; +} + +function createResponseStream(xhr: XMLHttpRequest): IXhrResponseStream { + let controller: ReadableStreamDefaultController | undefined; + let textLength = 0; + const encoder = new TextEncoder(); + + const data = new ReadableStream({ + start(value) { + controller = value; + } + }); + + return { + data, + append() { + const text = xhr.responseText; + const chunk = text.slice(textLength); + + if (chunk) { + controller?.enqueue(encoder.encode(chunk)); + textLength = text.length; + } + }, + close() { + controller?.close(); + }, + error(reason) { + controller?.error(reason); + } + }; +} + +async function readBlobAsText(blob: Blob): Promise { + if (typeof blob.text === 'function') { + return blob.text(); + } + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => { + if (typeof reader.result !== 'string') { + reject(new TypeError('Failed to read response Blob as text')); + return; + } + + resolve(reader.result); + }; + reader.onerror = () => { + reject(reader.error ?? new TypeError('Failed to read response Blob as text')); + }; + reader.readAsText(blob); + }); +} + +function hasEmptyResponseBody(xhr: XMLHttpRequest): boolean { + return xhr.status === 204 || xhr.getResponseHeader('content-length') === '0'; +} + +function getResponseBody(xhr: XMLHttpRequest, config: IHttpRequestConfig, responseType?: THttpResponseType): T { + if (hasEmptyResponseBody(xhr)) { + return undefined as T; + } + + switch (responseType) { + case HTTP_RESPONSE_TYPES.ARRAY_BUFFER: + case HTTP_RESPONSE_TYPES.BLOB: + return xhr.response as T; + case HTTP_RESPONSE_TYPES.TEXT: + return xhr.response as T; + case HTTP_RESPONSE_TYPES.JSON: + default: { + const resolvedResponseType = responseType ?? HTTP_RESPONSE_TYPES.JSON; + const text = xhr.response as string; + if (!text) { + return undefined as T; + } + try { + return JSON.parse(text) as T; + } catch (error) { + throw new ParseError('Failed to parse response body as JSON', { + cause: error, + config, + responseType: resolvedResponseType, + raw: text + }); + } + } + } +} + +interface IPreparedXhrRequest { + readonly xhr: XMLHttpRequest; + readonly body: ReturnType | undefined; + cleanup(): void; +} + +function setResponseType(xhr: XMLHttpRequest, responseType: THttpResponseType | undefined): void { + if (responseType === HTTP_RESPONSE_TYPES.ARRAY_BUFFER) { + // XHR accepts only a lowercase DOM value. + xhr.responseType = 'arraybuffer'; + } else if (responseType === HTTP_RESPONSE_TYPES.BLOB) { + xhr.responseType = 'blob'; + } else { + xhr.responseType = 'text'; + } +} + +function assertStreamingSupported(responseType: THttpResponseType | undefined, config: IHttpRequestConfig): void { + if ( + responseType === HTTP_RESPONSE_TYPES.STREAM && + (typeof ReadableStream === 'undefined' || typeof TextEncoder === 'undefined') + ) { + throw new RequestPreparationError('Streaming responses are not supported in this environment', { config }); + } +} + +function createResponse(xhr: XMLHttpRequest, config: IHttpRequestConfig, data: T): IHttpResponse { + return { + data, + status: xhr.status, + statusText: xhr.statusText, + headers: parseResponseHeaders(xhr.getAllResponseHeaders()), + config + }; +} + +async function getErrorResponseText(xhr: XMLHttpRequest): Promise { + if (xhr.responseType === 'blob') { + const blob: unknown = xhr.response; + + if (!(blob instanceof Blob)) { + return undefined; + } + + return readBlobAsText(blob); + } + + if (xhr.responseType === 'arraybuffer') { + const arrayBuffer: unknown = xhr.response; + + if (typeof TextDecoder === 'undefined' || !(arrayBuffer instanceof ArrayBuffer)) { + return undefined; + } + + return new TextDecoder().decode(arrayBuffer); + } + + return xhr.responseText; +} + +async function getErrorData(xhr: XMLHttpRequest): Promise { + try { + const text = await getErrorResponseText(xhr); + if (!text) { + return undefined; + } + + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } + } catch { + return undefined; + } +} + +async function createResponseError(xhr: XMLHttpRequest, config: IHttpRequestConfig): Promise { + return new HttpResponseError( + `Request failed with status code ${xhr.status}`, + xhr.status, + xhr.statusText, + parseResponseHeaders(xhr.getAllResponseHeaders()), + config, + await getErrorData(xhr) + ); +} + +function resolveRequestConfig(config: IHttpRequestConfig, options: IXhrAdapterOptions): IHttpRequestConfig { + return { + ...config, + ...(config.responseType === undefined && options.responseType !== undefined + ? { responseType: options.responseType } + : {}), + ...(config.timeout === undefined && options.timeout !== undefined ? { timeout: options.timeout } : {}), + ...(config.withCredentials === undefined && options.withCredentials !== undefined + ? { withCredentials: options.withCredentials } + : {}) + }; +} + +function prepareXhrRequest(config: IHttpRequestConfig, options: IXhrAdapterOptions): IPreparedXhrRequest { + const { + url, + method, + headers = {}, + params, + data, + signal: userSignal, + timeout, + responseType, + baseUrl: baseURL + } = config; + const fullUrl = buildUrl(baseURL, url, params); + + if (userSignal?.aborted) { + throw new AbortError('Request was aborted', { cause: userSignal.reason, config }); + } + + assertStreamingSupported(responseType, config); + const xhr = new XMLHttpRequest(); + xhr.open(method, fullUrl, true); + xhr.withCredentials = config.withCredentials === true; + + if (options.mimeType !== undefined) { + xhr.overrideMimeType(options.mimeType); + } + + if (timeout !== undefined && timeout > 0) { + xhr.timeout = timeout; + } + + setResponseType(xhr, responseType); + + const requestHeaders = mergeHeaders(headers); + const body = + data !== undefined && method !== HTTP_METHODS.GET && method !== HTTP_METHODS.HEAD + ? prepareRequestBody(data, requestHeaders) + : undefined; + for (const [key, value] of Object.entries(requestHeaders)) { + xhr.setRequestHeader(key, value); + } + + const onAbort = (): void => xhr.abort(); + userSignal?.addEventListener('abort', onAbort, { once: true }); + + return { + xhr, + body, + cleanup() { + userSignal?.removeEventListener('abort', onAbort); + } + }; +} + +function configureXhrEventHandlers( + request: IPreparedXhrRequest, + config: IHttpRequestConfig, + options: IXhrAdapterOptions, + resolve: (value: IHttpResponse | PromiseLike>) => void, + reject: (reason?: unknown) => void +): void { + const { xhr } = request; + const { signal: userSignal, timeout, responseType } = config; + const { onDownloadProgress, onUploadProgress } = options; + let responseStream: IXhrResponseStream | undefined; + let streamResponseResolved = false; + let statusValidation: { accepted: boolean } | { error: unknown } | undefined; + const isSuccessful = (): boolean => { + if (statusValidation === undefined) { + try { + statusValidation = { accepted: isStatusAccepted(xhr.status, config.validateStatus) }; + } catch (error) { + statusValidation = { error }; + } + } + + if ('error' in statusValidation) { + throw statusValidation.error; + } + + return statusValidation.accepted; + }; + const resolveStream = (): void => { + if (streamResponseResolved || !isSuccessful()) { + return; + } + + responseStream = createResponseStream(xhr); + streamResponseResolved = true; + resolve(createResponse(xhr, config, responseStream.data as T)); + }; + const rejectOrFailStream = (error: HttpClientError): void => { + request.cleanup(); + + if (streamResponseResolved) { + responseStream?.error(error); + return; + } + + reject(error); + }; + + if (responseType === HTTP_RESPONSE_TYPES.STREAM) { + xhr.onreadystatechange = () => { + if (xhr.readyState === XMLHttpRequest.HEADERS_RECEIVED) { + try { + resolveStream(); + } catch (error) { + request.cleanup(); + reject(error); + xhr.abort(); + } + } + }; + } + + if (responseType === HTTP_RESPONSE_TYPES.STREAM || onDownloadProgress !== undefined) { + xhr.onprogress = event => { + responseStream?.append(); + onDownloadProgress?.(event, config); + }; + } + + if (request.body !== undefined && onUploadProgress !== undefined) { + xhr.upload.onprogress = event => { + onUploadProgress(event, config); + }; + } + + xhr.onload = async () => { + request.cleanup(); + + let successful: boolean; + + try { + successful = isSuccessful(); + } catch (error) { + reject(error); + return; + } + + if (!successful) { + reject(await createResponseError(xhr, config)); + return; + } + + if (responseType === HTTP_RESPONSE_TYPES.STREAM) { + resolveStream(); + responseStream?.append(); + responseStream?.close(); + + return; + } + + try { + const responseData = getResponseBody(xhr, config, responseType); + resolve(createResponse(xhr, config, responseData)); + } catch (error) { + reject( + error instanceof HttpClientError + ? error + : new NetworkError(getErrorMessage(error, 'Network request failed'), { cause: error, config }) + ); + } + }; + + xhr.onerror = event => { + rejectOrFailStream(new NetworkError('Network request failed', { cause: event, config })); + }; + + xhr.onabort = event => { + rejectOrFailStream(new AbortError('Request was aborted', { cause: userSignal?.reason ?? event, config })); + }; + + xhr.ontimeout = event => { + const timeoutMs = timeout ?? 0; + + rejectOrFailStream( + new TimeoutError(`Request timed out after ${timeoutMs}ms`, { + cause: event, + config, + timeout: timeoutMs + }) + ); + }; +} + +export class XhrAdapter implements IHttpClientAdapter { + private readonly options: IXhrAdapterOptions; + + constructor(options: IXhrAdapterOptions = {}) { + assertValidXhrAdapterOptions(options); + + this.options = { ...options }; + } + + request(config: IHttpRequestConfig): Promise> { + const resolvedConfig = resolveRequestConfig(config, this.options); + + return new Promise>((resolve, reject) => { + let request: IPreparedXhrRequest | undefined; + + try { + request = prepareXhrRequest(resolvedConfig, this.options); + configureXhrEventHandlers(request, resolvedConfig, this.options, resolve, reject); + request.xhr.send(request.body); + } catch (error) { + request?.cleanup(); + reject( + error instanceof HttpClientError + ? error + : new RequestPreparationError('Failed to prepare HTTP request', { + cause: error, + config: resolvedConfig + }) + ); + } + }); + } +} diff --git a/services/http-client/src/adapters/index.ts b/services/http-client/src/adapters/index.ts new file mode 100644 index 00000000..1f4ec124 --- /dev/null +++ b/services/http-client/src/adapters/index.ts @@ -0,0 +1,2 @@ +export { FetchAdapter } from './FetchAdapter'; +export { XhrAdapter } from './XhrAdapter'; diff --git a/services/http-client/src/asserts/assertNonBlankString.ts b/services/http-client/src/asserts/assertNonBlankString.ts new file mode 100644 index 00000000..94d6d962 --- /dev/null +++ b/services/http-client/src/asserts/assertNonBlankString.ts @@ -0,0 +1,12 @@ +import { RequestBuilderError } from '../errors'; +import { TRequestBuilderErrorCode } from '../types'; + +export function assertNonBlankString( + value: unknown, + message: string, + code: TRequestBuilderErrorCode +): asserts value is string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new RequestBuilderError(message, code); + } +} diff --git a/services/http-client/src/asserts/assertValidAdapter.ts b/services/http-client/src/asserts/assertValidAdapter.ts new file mode 100644 index 00000000..deb57db7 --- /dev/null +++ b/services/http-client/src/asserts/assertValidAdapter.ts @@ -0,0 +1,17 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; +import { IHttpClientAdapter } from '../types'; + +/** Validates an HTTP client adapter. */ +export function assertValidAdapter(adapter: unknown): asserts adapter is IHttpClientAdapter { + if ( + adapter === null || + (typeof adapter !== 'object' && typeof adapter !== 'function') || + typeof (adapter as IHttpClientAdapter).request !== 'function' + ) { + throw new RequestBuilderError( + 'Adapter must implement a request method', + REQUEST_BUILDER_ERROR_CODES.INVALID_ADAPTER + ); + } +} diff --git a/services/http-client/src/asserts/assertValidBaseUrl.ts b/services/http-client/src/asserts/assertValidBaseUrl.ts new file mode 100644 index 00000000..5548f181 --- /dev/null +++ b/services/http-client/src/asserts/assertValidBaseUrl.ts @@ -0,0 +1,6 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { assertNonBlankString } from './assertNonBlankString'; + +export function assertValidBaseUrl(baseUrl: unknown): asserts baseUrl is string { + assertNonBlankString(baseUrl, 'Base URL must be a non-empty string', REQUEST_BUILDER_ERROR_CODES.INVALID_BASE_URL); +} diff --git a/services/http-client/src/asserts/assertValidBody.ts b/services/http-client/src/asserts/assertValidBody.ts new file mode 100644 index 00000000..86180efe --- /dev/null +++ b/services/http-client/src/asserts/assertValidBody.ts @@ -0,0 +1,17 @@ +import { HTTP_METHODS, REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; +import { THttpMethod } from '../types'; + +/** Validates that a request method supports a body and that the body is explicitly defined. */ +export function assertValidBody(method: THttpMethod, data: unknown): void { + if (method === HTTP_METHODS.GET || method === HTTP_METHODS.HEAD) { + throw new RequestBuilderError( + 'Body is not allowed for GET or HEAD requests', + REQUEST_BUILDER_ERROR_CODES.INVALID_BODY + ); + } + + if (data === undefined) { + throw new RequestBuilderError('Body must not be undefined', REQUEST_BUILDER_ERROR_CODES.INVALID_BODY); + } +} diff --git a/services/http-client/src/asserts/assertValidFetchAdapterOptions.ts b/services/http-client/src/asserts/assertValidFetchAdapterOptions.ts new file mode 100644 index 00000000..aab53c57 --- /dev/null +++ b/services/http-client/src/asserts/assertValidFetchAdapterOptions.ts @@ -0,0 +1,81 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; +import { IFetchAdapterOptions } from '../types'; +import { isRecord } from './isRecord'; + +const FETCH_CACHE_VALUES = ['default', 'no-store', 'reload', 'no-cache', 'force-cache', 'only-if-cached']; +const FETCH_CREDENTIALS_VALUES = ['omit', 'same-origin', 'include']; +const FETCH_MODE_VALUES = ['cors', 'no-cors', 'same-origin']; +const FETCH_REDIRECT_VALUES = ['error', 'follow', 'manual']; +const FETCH_REFERRER_POLICY_VALUES = [ + '', + 'no-referrer', + 'no-referrer-when-downgrade', + 'origin', + 'origin-when-cross-origin', + 'same-origin', + 'strict-origin', + 'strict-origin-when-cross-origin', + 'unsafe-url' +]; + +function throwInvalidOptions(message: string): never { + throw new RequestBuilderError(message, REQUEST_BUILDER_ERROR_CODES.INVALID_FETCH_ADAPTER_OPTIONS); +} + +function assertValidEnum(value: unknown, values: readonly string[], name: string): void { + if (typeof value !== 'string' || !values.includes(value)) { + throwInvalidOptions(`${name} must be a valid Fetch option`); + } +} + +function assertValidString(value: unknown, name: string): void { + if (typeof value !== 'string') { + throwInvalidOptions(`${name} must be a string`); + } +} + +/** Validates options passed to the Fetch adapter constructor. */ +export function assertValidFetchAdapterOptions(options: unknown): asserts options is IFetchAdapterOptions { + if (!isRecord(options)) { + throwInvalidOptions('Fetch adapter options must be an object'); + } + + const { cache, credentials, integrity, keepalive, mode, redirect, referrer, referrerPolicy } = options; + + if (cache !== undefined) { + assertValidEnum(cache, FETCH_CACHE_VALUES, 'cache'); + } + + if (credentials !== undefined) { + assertValidEnum(credentials, FETCH_CREDENTIALS_VALUES, 'credentials'); + } + + if (integrity !== undefined) { + assertValidString(integrity, 'integrity'); + } + + if (keepalive !== undefined && typeof keepalive !== 'boolean') { + throwInvalidOptions('keepalive must be a boolean'); + } + + if (mode !== undefined) { + assertValidEnum(mode, FETCH_MODE_VALUES, 'mode'); + } + + if (cache === 'only-if-cached' && mode !== 'same-origin') { + throwInvalidOptions('cache "only-if-cached" requires mode "same-origin"'); + } + + if (redirect !== undefined) { + assertValidEnum(redirect, FETCH_REDIRECT_VALUES, 'redirect'); + } + + if (referrer !== undefined) { + assertValidString(referrer, 'referrer'); + } + + if (referrerPolicy !== undefined) { + assertValidEnum(referrerPolicy, FETCH_REFERRER_POLICY_VALUES, 'referrerPolicy'); + } +} diff --git a/services/http-client/src/asserts/assertValidHeader.ts b/services/http-client/src/asserts/assertValidHeader.ts new file mode 100644 index 00000000..e5b1b523 --- /dev/null +++ b/services/http-client/src/asserts/assertValidHeader.ts @@ -0,0 +1,22 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; +import { assertNonBlankString } from './assertNonBlankString'; +import { HEADER_VALUE_LINE_BREAK_PATTERN } from './headerValueLineBreakPattern'; + +export function assertValidHeader(key: unknown, value: unknown): asserts value is string { + assertNonBlankString(key, 'Header key must be a non-empty string', REQUEST_BUILDER_ERROR_CODES.INVALID_HEADER); + + if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(key)) { + throw new RequestBuilderError( + 'Header key contains invalid characters', + REQUEST_BUILDER_ERROR_CODES.INVALID_HEADER + ); + } + + if (typeof value !== 'string' || HEADER_VALUE_LINE_BREAK_PATTERN.test(value)) { + throw new RequestBuilderError( + 'Header value must be a string without line breaks', + REQUEST_BUILDER_ERROR_CODES.INVALID_HEADER + ); + } +} diff --git a/services/http-client/src/asserts/assertValidHeaders.ts b/services/http-client/src/asserts/assertValidHeaders.ts new file mode 100644 index 00000000..c4621479 --- /dev/null +++ b/services/http-client/src/asserts/assertValidHeaders.ts @@ -0,0 +1,15 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; +import { THttpHeaders } from '../types'; +import { assertValidHeader } from './assertValidHeader'; +import { isRecord } from './isRecord'; + +export function assertValidHeaders(headers: unknown): asserts headers is THttpHeaders { + if (!isRecord(headers)) { + throw new RequestBuilderError('Headers must be an object', REQUEST_BUILDER_ERROR_CODES.INVALID_HEADERS); + } + + for (const [key, value] of Object.entries(headers)) { + assertValidHeader(key, value); + } +} diff --git a/services/http-client/src/asserts/assertValidHttpClientOptions.ts b/services/http-client/src/asserts/assertValidHttpClientOptions.ts new file mode 100644 index 00000000..6e9c8da5 --- /dev/null +++ b/services/http-client/src/asserts/assertValidHttpClientOptions.ts @@ -0,0 +1,82 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; +import { IHttpClientOptions } from '../types'; +import { assertValidAdapter } from './assertValidAdapter'; +import { assertValidBaseUrl } from './assertValidBaseUrl'; +import { assertValidHeaders } from './assertValidHeaders'; +import { assertValidParams } from './assertValidParams'; +import { assertValidTimeout } from './assertValidTimeout'; +import { assertValidWithCredentials } from './assertValidWithCredentials'; +import { assertValidValidateStatus } from './assertValidValidateStatus'; +import { isRecord } from './isRecord'; + +function assertValidHook(hook: unknown, name: string): asserts hook is (...args: never[]) => unknown { + if (typeof hook !== 'function') { + throw new RequestBuilderError(`${name} must be a function`, REQUEST_BUILDER_ERROR_CODES.INVALID_HOOK); + } +} + +/** Validates options passed to the HTTP client constructor. */ +export function assertValidHttpClientOptions(options: unknown): asserts options is IHttpClientOptions { + if (!isRecord(options)) { + throw new RequestBuilderError('Client options must be an object', REQUEST_BUILDER_ERROR_CODES.INVALID_CONFIG); + } + + const { + adapter, + baseUrl, + headers, + params, + timeout, + validateStatus, + withCredentials, + onRequest, + onRequestError, + onResponse, + onResponseError + } = options; + + if (adapter !== undefined) { + assertValidAdapter(adapter); + } + + if (baseUrl !== undefined) { + assertValidBaseUrl(baseUrl); + } + + if (headers !== undefined) { + assertValidHeaders(headers); + } + + if (params !== undefined) { + assertValidParams(params); + } + + if (timeout !== undefined) { + assertValidTimeout(timeout); + } + + if (validateStatus !== undefined) { + assertValidValidateStatus(validateStatus); + } + + if (withCredentials !== undefined) { + assertValidWithCredentials(withCredentials); + } + + if (onRequest !== undefined) { + assertValidHook(onRequest, 'onRequest'); + } + + if (onRequestError !== undefined) { + assertValidHook(onRequestError, 'onRequestError'); + } + + if (onResponse !== undefined) { + assertValidHook(onResponse, 'onResponse'); + } + + if (onResponseError !== undefined) { + assertValidHook(onResponseError, 'onResponseError'); + } +} diff --git a/services/http-client/src/asserts/assertValidMethod.ts b/services/http-client/src/asserts/assertValidMethod.ts new file mode 100644 index 00000000..7343a5ef --- /dev/null +++ b/services/http-client/src/asserts/assertValidMethod.ts @@ -0,0 +1,9 @@ +import { HTTP_METHODS, REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; +import { THttpMethod } from '../types'; + +export function assertValidMethod(method: unknown): asserts method is THttpMethod { + if (!Object.values(HTTP_METHODS).includes(method as THttpMethod)) { + throw new RequestBuilderError('Method must be a valid HTTP method', REQUEST_BUILDER_ERROR_CODES.INVALID_METHOD); + } +} diff --git a/services/http-client/src/asserts/assertValidParam.ts b/services/http-client/src/asserts/assertValidParam.ts new file mode 100644 index 00000000..17e152ca --- /dev/null +++ b/services/http-client/src/asserts/assertValidParam.ts @@ -0,0 +1,31 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; +import { THttpParamPrimitive, THttpParamValue } from '../types'; +import { assertNonBlankString } from './assertNonBlankString'; + +function isValidParamPrimitive(value: unknown): value is THttpParamPrimitive { + return ( + typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value)) + ); +} + +function isValidParamValue(value: unknown): value is THttpParamValue { + if (value === null || value === undefined || isValidParamPrimitive(value)) { + return true; + } + + return ( + Array.isArray(value) && value.every(item => item === null || item === undefined || isValidParamPrimitive(item)) + ); +} + +export function assertValidParam(key: unknown, value: unknown): asserts value is THttpParamValue { + assertNonBlankString(key, 'Param key must be a non-empty string', REQUEST_BUILDER_ERROR_CODES.INVALID_PARAM); + + if (!isValidParamValue(value)) { + throw new RequestBuilderError( + 'Param value must be a string, finite number, boolean, null, undefined, or an array of these values', + REQUEST_BUILDER_ERROR_CODES.INVALID_PARAM + ); + } +} diff --git a/services/http-client/src/asserts/assertValidParams.ts b/services/http-client/src/asserts/assertValidParams.ts new file mode 100644 index 00000000..4ac396c1 --- /dev/null +++ b/services/http-client/src/asserts/assertValidParams.ts @@ -0,0 +1,15 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; +import { THttpParams } from '../types'; +import { assertValidParam } from './assertValidParam'; +import { isRecord } from './isRecord'; + +export function assertValidParams(params: unknown): asserts params is THttpParams { + if (!isRecord(params)) { + throw new RequestBuilderError('Params must be an object', REQUEST_BUILDER_ERROR_CODES.INVALID_PARAMS); + } + + for (const [key, value] of Object.entries(params)) { + assertValidParam(key, value); + } +} diff --git a/services/http-client/src/asserts/assertValidRequestConfig.ts b/services/http-client/src/asserts/assertValidRequestConfig.ts new file mode 100644 index 00000000..e6c13525 --- /dev/null +++ b/services/http-client/src/asserts/assertValidRequestConfig.ts @@ -0,0 +1,75 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; +import { IHttpRequestConfig } from '../types'; +import { assertValidBody } from './assertValidBody'; +import { assertValidBaseUrl } from './assertValidBaseUrl'; +import { assertValidHeaders } from './assertValidHeaders'; +import { assertValidMethod } from './assertValidMethod'; +import { assertValidParams } from './assertValidParams'; +import { assertValidResponseType } from './assertValidResponseType'; +import { assertValidSignal } from './assertValidSignal'; +import { assertValidTimeout } from './assertValidTimeout'; +import { assertValidUrl } from './assertValidUrl'; +import { assertValidWithCredentials } from './assertValidWithCredentials'; +import { assertValidValidateStatus } from './assertValidValidateStatus'; +import { isRecord } from './isRecord'; + +/** Validates a complete request config, including values returned by request hooks. */ +export function assertValidRequestConfig(config: unknown): asserts config is IHttpRequestConfig { + if (!isRecord(config)) { + throw new RequestBuilderError('Request config must be an object', REQUEST_BUILDER_ERROR_CODES.INVALID_CONFIG); + } + + const { + method, + url, + baseUrl, + headers, + params, + data, + signal, + timeout, + validateStatus, + withCredentials, + responseType + } = config; + + assertValidMethod(method); + assertValidUrl(url); + + if (baseUrl !== undefined) { + assertValidBaseUrl(baseUrl); + } + + if (headers !== undefined) { + assertValidHeaders(headers); + } + + if (params !== undefined) { + assertValidParams(params); + } + + if (signal !== undefined) { + assertValidSignal(signal); + } + + if (timeout !== undefined) { + assertValidTimeout(timeout); + } + + if (validateStatus !== undefined) { + assertValidValidateStatus(validateStatus); + } + + if (withCredentials !== undefined) { + assertValidWithCredentials(withCredentials); + } + + if (responseType !== undefined) { + assertValidResponseType(responseType); + } + + if (Object.prototype.hasOwnProperty.call(config, 'data')) { + assertValidBody(method, data); + } +} diff --git a/services/http-client/src/asserts/assertValidResponseType.ts b/services/http-client/src/asserts/assertValidResponseType.ts new file mode 100644 index 00000000..c7df52d0 --- /dev/null +++ b/services/http-client/src/asserts/assertValidResponseType.ts @@ -0,0 +1,12 @@ +import { HTTP_RESPONSE_TYPES, REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; +import { THttpResponseType } from '../types'; + +export function assertValidResponseType(responseType: unknown): asserts responseType is THttpResponseType { + if (!Object.values(HTTP_RESPONSE_TYPES).includes(responseType as THttpResponseType)) { + throw new RequestBuilderError( + 'Response type must be a valid HTTP response type', + REQUEST_BUILDER_ERROR_CODES.INVALID_RESPONSE_TYPE + ); + } +} diff --git a/services/http-client/src/asserts/assertValidSignal.ts b/services/http-client/src/asserts/assertValidSignal.ts new file mode 100644 index 00000000..13a71ce9 --- /dev/null +++ b/services/http-client/src/asserts/assertValidSignal.ts @@ -0,0 +1,14 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; + +export function assertValidSignal(signal: unknown): asserts signal is AbortSignal { + if ( + signal === null || + typeof signal !== 'object' || + typeof (signal as AbortSignal).aborted !== 'boolean' || + typeof (signal as AbortSignal).addEventListener !== 'function' || + typeof (signal as AbortSignal).removeEventListener !== 'function' + ) { + throw new RequestBuilderError('Signal must be an AbortSignal', REQUEST_BUILDER_ERROR_CODES.INVALID_SIGNAL); + } +} diff --git a/services/http-client/src/asserts/assertValidTimeout.ts b/services/http-client/src/asserts/assertValidTimeout.ts new file mode 100644 index 00000000..59b976c9 --- /dev/null +++ b/services/http-client/src/asserts/assertValidTimeout.ts @@ -0,0 +1,11 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; + +export function assertValidTimeout(timeout: unknown): asserts timeout is number { + if (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout < 0) { + throw new RequestBuilderError( + 'Timeout must be a finite non-negative number', + REQUEST_BUILDER_ERROR_CODES.INVALID_TIMEOUT + ); + } +} diff --git a/services/http-client/src/asserts/assertValidUrl.ts b/services/http-client/src/asserts/assertValidUrl.ts new file mode 100644 index 00000000..0b298093 --- /dev/null +++ b/services/http-client/src/asserts/assertValidUrl.ts @@ -0,0 +1,6 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { assertNonBlankString } from './assertNonBlankString'; + +export function assertValidUrl(url: unknown): asserts url is string { + assertNonBlankString(url, 'URL must be a non-empty string', REQUEST_BUILDER_ERROR_CODES.INVALID_URL); +} diff --git a/services/http-client/src/asserts/assertValidValidateStatus.ts b/services/http-client/src/asserts/assertValidValidateStatus.ts new file mode 100644 index 00000000..2e9b968f --- /dev/null +++ b/services/http-client/src/asserts/assertValidValidateStatus.ts @@ -0,0 +1,13 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; +import { TValidateStatus } from '../types'; + +/** Validates a custom HTTP response status predicate. */ +export function assertValidValidateStatus(value: unknown): asserts value is TValidateStatus { + if (typeof value !== 'function') { + throw new RequestBuilderError( + 'validateStatus must be a function', + REQUEST_BUILDER_ERROR_CODES.INVALID_VALIDATE_STATUS + ); + } +} diff --git a/services/http-client/src/asserts/assertValidWithCredentials.ts b/services/http-client/src/asserts/assertValidWithCredentials.ts new file mode 100644 index 00000000..a7f89c2b --- /dev/null +++ b/services/http-client/src/asserts/assertValidWithCredentials.ts @@ -0,0 +1,11 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; + +export function assertValidWithCredentials(withCredentials: unknown): asserts withCredentials is boolean { + if (typeof withCredentials !== 'boolean') { + throw new RequestBuilderError( + 'withCredentials must be a boolean', + REQUEST_BUILDER_ERROR_CODES.INVALID_WITH_CREDENTIALS + ); + } +} diff --git a/services/http-client/src/asserts/assertValidXhrAdapterOptions.ts b/services/http-client/src/asserts/assertValidXhrAdapterOptions.ts new file mode 100644 index 00000000..5b475a4e --- /dev/null +++ b/services/http-client/src/asserts/assertValidXhrAdapterOptions.ts @@ -0,0 +1,44 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; +import { IXhrAdapterOptions } from '../types'; +import { assertValidResponseType } from './assertValidResponseType'; +import { assertValidTimeout } from './assertValidTimeout'; +import { assertValidWithCredentials } from './assertValidWithCredentials'; +import { isRecord } from './isRecord'; + +function throwInvalidOptions(message: string): never { + throw new RequestBuilderError(message, REQUEST_BUILDER_ERROR_CODES.INVALID_XHR_ADAPTER_OPTIONS); +} + +/** Validates options passed to the XMLHttpRequest adapter constructor. */ +export function assertValidXhrAdapterOptions(options: unknown): asserts options is IXhrAdapterOptions { + if (!isRecord(options)) { + throwInvalidOptions('XHR adapter options must be an object'); + } + + const { mimeType, responseType, timeout, withCredentials, onDownloadProgress, onUploadProgress } = options; + + if (mimeType !== undefined && (typeof mimeType !== 'string' || mimeType.trim().length === 0)) { + throwInvalidOptions('mimeType must be a non-empty string'); + } + + if (responseType !== undefined) { + assertValidResponseType(responseType); + } + + if (timeout !== undefined) { + assertValidTimeout(timeout); + } + + if (withCredentials !== undefined) { + assertValidWithCredentials(withCredentials); + } + + if (onDownloadProgress !== undefined && typeof onDownloadProgress !== 'function') { + throwInvalidOptions('onDownloadProgress must be a function'); + } + + if (onUploadProgress !== undefined && typeof onUploadProgress !== 'function') { + throwInvalidOptions('onUploadProgress must be a function'); + } +} diff --git a/services/http-client/src/asserts/headerValueLineBreakPattern.ts b/services/http-client/src/asserts/headerValueLineBreakPattern.ts new file mode 100644 index 00000000..e397ccef --- /dev/null +++ b/services/http-client/src/asserts/headerValueLineBreakPattern.ts @@ -0,0 +1 @@ +export const HEADER_VALUE_LINE_BREAK_PATTERN = /[\r\n]/; diff --git a/services/http-client/src/asserts/index.ts b/services/http-client/src/asserts/index.ts new file mode 100644 index 00000000..d104d458 --- /dev/null +++ b/services/http-client/src/asserts/index.ts @@ -0,0 +1,20 @@ +export * from './assertValidBody'; +export * from './assertValidAdapter'; +export * from './assertValidFetchAdapterOptions'; +export * from './assertNonBlankString'; +export * from './assertValidBaseUrl'; +export * from './assertValidHeader'; +export * from './assertValidHeaders'; +export * from './assertValidHttpClientOptions'; +export * from './assertValidMethod'; +export * from './assertValidParam'; +export * from './assertValidParams'; +export * from './assertValidRequestConfig'; +export * from './assertValidResponseType'; +export * from './assertValidSignal'; +export * from './assertValidTimeout'; +export * from './assertValidUrl'; +export * from './assertValidValidateStatus'; +export * from './assertValidWithCredentials'; +export * from './assertValidXhrAdapterOptions'; +export * from './headerValueLineBreakPattern'; diff --git a/services/http-client/src/asserts/isRecord.ts b/services/http-client/src/asserts/isRecord.ts new file mode 100644 index 00000000..ea91dffa --- /dev/null +++ b/services/http-client/src/asserts/isRecord.ts @@ -0,0 +1,9 @@ +export function isRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + + const prototype = Object.getPrototypeOf(value) as object | null; + + return prototype === Object.prototype || prototype === null; +} diff --git a/services/http-client/src/constants/httpMethods.ts b/services/http-client/src/constants/httpMethods.ts new file mode 100644 index 00000000..a44489c0 --- /dev/null +++ b/services/http-client/src/constants/httpMethods.ts @@ -0,0 +1,9 @@ +export const HTTP_METHODS = { + GET: 'GET', + HEAD: 'HEAD', + POST: 'POST', + PUT: 'PUT', + DELETE: 'DELETE', + OPTIONS: 'OPTIONS', + PATCH: 'PATCH' +} as const; diff --git a/services/http-client/src/constants/httpResponseTypes.ts b/services/http-client/src/constants/httpResponseTypes.ts new file mode 100644 index 00000000..8e67908b --- /dev/null +++ b/services/http-client/src/constants/httpResponseTypes.ts @@ -0,0 +1,7 @@ +export const HTTP_RESPONSE_TYPES = { + ARRAY_BUFFER: 'arrayBuffer', + BLOB: 'blob', + JSON: 'json', + STREAM: 'stream', + TEXT: 'text' +} as const; diff --git a/services/http-client/src/constants/httpStatusCodes.ts b/services/http-client/src/constants/httpStatusCodes.ts new file mode 100644 index 00000000..41f8df8a --- /dev/null +++ b/services/http-client/src/constants/httpStatusCodes.ts @@ -0,0 +1,64 @@ +export const HTTP_STATUS_CODES = { + CONTINUE: 100, + SWITCHING_PROTOCOLS: 101, + PROCESSING: 102, + EARLY_HINTS: 103, + OK: 200, + CREATED: 201, + ACCEPTED: 202, + NON_AUTHORITATIVE_INFORMATION: 203, + NO_CONTENT: 204, + RESET_CONTENT: 205, + PARTIAL_CONTENT: 206, + MULTI_STATUS: 207, + ALREADY_REPORTED: 208, + IM_USED: 226, + MULTIPLE_CHOICES: 300, + MOVED_PERMANENTLY: 301, + FOUND: 302, + SEE_OTHER: 303, + NOT_MODIFIED: 304, + USE_PROXY: 305, + 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, + RANGE_NOT_SATISFIABLE: 416, + EXPECTATION_FAILED: 417, + IM_A_TEAPOT: 418, + MISDIRECTED_REQUEST: 421, + UNPROCESSABLE_ENTITY: 422, + LOCKED: 423, + FAILED_DEPENDENCY: 424, + TOO_EARLY: 425, + UPGRADE_REQUIRED: 426, + PRECONDITION_REQUIRED: 428, + TOO_MANY_REQUESTS: 429, + REQUEST_HEADER_FIELDS_TOO_LARGE: 431, + UNAVAILABLE_FOR_LEGAL_REASONS: 451, + INTERNAL_SERVER_ERROR: 500, + NOT_IMPLEMENTED: 501, + BAD_GATEWAY: 502, + SERVICE_UNAVAILABLE: 503, + GATEWAY_TIMEOUT: 504, + HTTP_VERSION_NOT_SUPPORTED: 505, + VARIANT_ALSO_NEGOTIATES: 506, + INSUFFICIENT_STORAGE: 507, + LOOP_DETECTED: 508, + NOT_EXTENDED: 510, + NETWORK_AUTHENTICATION_REQUIRED: 511 +} as const; diff --git a/services/http-client/src/constants/index.ts b/services/http-client/src/constants/index.ts new file mode 100644 index 00000000..4c9d3e40 --- /dev/null +++ b/services/http-client/src/constants/index.ts @@ -0,0 +1,4 @@ +export * from './httpMethods'; +export * from './httpStatusCodes'; +export * from './httpResponseTypes'; +export * from './requestBuilderErrorCodes'; diff --git a/services/http-client/src/constants/requestBuilderErrorCodes.ts b/services/http-client/src/constants/requestBuilderErrorCodes.ts new file mode 100644 index 00000000..db4668ca --- /dev/null +++ b/services/http-client/src/constants/requestBuilderErrorCodes.ts @@ -0,0 +1,22 @@ +export const REQUEST_BUILDER_ERROR_CODES = { + INVALID_BASE_URL: 'INVALID_BASE_URL', + INVALID_BEARER_TOKEN: 'INVALID_BEARER_TOKEN', + INVALID_ADAPTER: 'INVALID_ADAPTER', + INVALID_BODY: 'INVALID_BODY', + INVALID_CONFIG: 'INVALID_CONFIG', + INVALID_EXECUTOR: 'INVALID_EXECUTOR', + INVALID_FETCH_ADAPTER_OPTIONS: 'INVALID_FETCH_ADAPTER_OPTIONS', + INVALID_HEADER: 'INVALID_HEADER', + INVALID_HEADERS: 'INVALID_HEADERS', + INVALID_HOOK: 'INVALID_HOOK', + INVALID_METHOD: 'INVALID_METHOD', + INVALID_PARAM: 'INVALID_PARAM', + INVALID_PARAMS: 'INVALID_PARAMS', + INVALID_RESPONSE_TYPE: 'INVALID_RESPONSE_TYPE', + INVALID_SIGNAL: 'INVALID_SIGNAL', + INVALID_TIMEOUT: 'INVALID_TIMEOUT', + INVALID_URL: 'INVALID_URL', + INVALID_VALIDATE_STATUS: 'INVALID_VALIDATE_STATUS', + INVALID_WITH_CREDENTIALS: 'INVALID_WITH_CREDENTIALS', + INVALID_XHR_ADAPTER_OPTIONS: 'INVALID_XHR_ADAPTER_OPTIONS' +} as const; diff --git a/services/http-client/src/core/HttpClient.ts b/services/http-client/src/core/HttpClient.ts new file mode 100644 index 00000000..1ad1e52a --- /dev/null +++ b/services/http-client/src/core/HttpClient.ts @@ -0,0 +1,187 @@ +import { HTTP_METHODS } from '../constants'; +import { assertValidAdapter, assertValidHttpClientOptions, assertValidRequestConfig } from '../asserts'; +import { FetchAdapter } from '../adapters'; +import { + THttpMethod, + IHttpRequestConfig, + IHttpResponse, + IHttpClientOptions, + IHttpClientAdapter, + THttpRequestExecutor, + THttpRequestHook, + THttpRequestErrorHook, + THttpResponseHook, + THttpResponseErrorHook +} from '../types'; +import { HttpRequestBuilder } from './HttpRequestBuilder'; +import { mergeHeaders, mergeParams } from '../utilities'; + +export class HttpClient { + private readonly adapter: IHttpClientAdapter; + private readonly defaultConfig: Pick< + IHttpRequestConfig, + 'baseUrl' | 'headers' | 'timeout' | 'validateStatus' | 'withCredentials' | 'params' + >; + private onRequestHook?: THttpRequestHook; + private onRequestErrorHook?: THttpRequestErrorHook; + private onResponseHook?: THttpResponseHook; + private onResponseErrorHook?: THttpResponseErrorHook; + + constructor(options: IHttpClientOptions) { + assertValidHttpClientOptions(options); + + this.adapter = options.adapter ?? new FetchAdapter(); + this.onRequestHook = options.onRequest; + this.onRequestErrorHook = options.onRequestError; + this.onResponseHook = options.onResponse; + this.onResponseErrorHook = options.onResponseError; + + this.defaultConfig = { + baseUrl: options.baseUrl, + headers: mergeHeaders(options.headers), + params: mergeParams(options.params), + timeout: options.timeout, + validateStatus: options.validateStatus, + withCredentials: options.withCredentials + }; + } + + /** Creates an independent client with the current defaults and hooks, using the provided adapter. */ + public withAdapter(adapter: IHttpClientAdapter): HttpClient { + assertValidAdapter(adapter); + + return new HttpClient({ + ...this.defaultConfig, + adapter, + onRequest: this.onRequestHook, + onRequestError: this.onRequestErrorHook, + onResponse: this.onResponseHook, + onResponseError: this.onResponseErrorHook + }); + } + + /** Sets the onRequest hook, replacing the hook configured through constructor options. Returns this client for chaining. */ + public onRequest(hook: THttpRequestHook): this { + this.onRequestHook = hook; + + return this; + } + + /** Sets the onRequestError hook, replacing the hook configured through constructor options. Returns this client for chaining. */ + public onRequestError(hook: THttpRequestErrorHook): this { + this.onRequestErrorHook = hook; + + return this; + } + + /** Sets the onResponse hook, replacing the hook configured through constructor options. Returns this client for chaining. */ + public onResponse(hook: THttpResponseHook): this { + this.onResponseHook = hook; + + return this; + } + + /** Sets the onResponseError hook, replacing the hook configured through constructor options. Returns this client for chaining. */ + public onResponseError(hook: THttpResponseErrorHook): this { + this.onResponseErrorHook = hook; + + return this; + } + + private request(method: THttpMethod, url: string): HttpRequestBuilder { + const executor: THttpRequestExecutor = (config: IHttpRequestConfig): Promise> => + this.executeRequest(config); + + return new HttpRequestBuilder(executor, method, url); + } + + public get(url: string): HttpRequestBuilder { + return this.request(HTTP_METHODS.GET, url); + } + + public head(url: string): HttpRequestBuilder { + return this.request(HTTP_METHODS.HEAD, url); + } + + public post(url: string): HttpRequestBuilder { + return this.request(HTTP_METHODS.POST, url); + } + + public put(url: string): HttpRequestBuilder { + return this.request(HTTP_METHODS.PUT, url); + } + + public delete(url: string): HttpRequestBuilder { + return this.request(HTTP_METHODS.DELETE, url); + } + + public options(url: string): HttpRequestBuilder { + return this.request(HTTP_METHODS.OPTIONS, url); + } + + public patch(url: string): HttpRequestBuilder { + return this.request(HTTP_METHODS.PATCH, url); + } + + private async executeRequest(config: IHttpRequestConfig): Promise> { + const { + onRequestHook: onRequest, + onRequestErrorHook: onRequestError, + onResponseHook: onResponse, + onResponseErrorHook: onResponseError + } = this; + + let mergedConfig: IHttpRequestConfig = { + ...config, + baseUrl: config.baseUrl ?? this.defaultConfig.baseUrl, + timeout: config.timeout ?? this.defaultConfig.timeout, + validateStatus: config.validateStatus ?? this.defaultConfig.validateStatus, + withCredentials: config.withCredentials ?? this.defaultConfig.withCredentials, + headers: mergeHeaders(this.defaultConfig.headers, config.headers), + params: mergeParams(this.defaultConfig.params, config.params) + }; + + if (onRequest !== undefined) { + try { + const requestConfig = await onRequest(mergedConfig); + assertValidRequestConfig(requestConfig); + mergedConfig = requestConfig; + } catch (error) { + if (onRequestError === undefined) { + throw error; + } + + const recoveredConfig = await onRequestError(error); + + if (recoveredConfig === undefined) { + throw error; + } + + assertValidRequestConfig(recoveredConfig); + mergedConfig = recoveredConfig; + } + } + + try { + let response = await this.adapter.request(mergedConfig); + + if (onResponse !== undefined) { + response = await onResponse(response); + } + + return response as IHttpResponse; + } catch (error) { + if (onResponseError === undefined) { + throw error; + } + + const recoveredResponse = await onResponseError(error); + + if (recoveredResponse === undefined) { + throw error; + } + + return recoveredResponse as IHttpResponse; + } + } +} diff --git a/services/http-client/src/core/HttpRequestBuilder.ts b/services/http-client/src/core/HttpRequestBuilder.ts new file mode 100644 index 00000000..09111977 --- /dev/null +++ b/services/http-client/src/core/HttpRequestBuilder.ts @@ -0,0 +1,182 @@ +import { HTTP_RESPONSE_TYPES, REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; +import { + THttpMethod, + IHttpRequestConfig, + IHttpResponse, + THttpHeaders, + THttpParams, + THttpParamValue, + THttpRequestExecutor +} from '../types'; +import { + HEADER_VALUE_LINE_BREAK_PATTERN, + assertValidBody, + assertNonBlankString, + assertValidBaseUrl, + assertValidHeader, + assertValidHeaders, + assertValidMethod, + assertValidParam, + assertValidParams, + assertValidRequestConfig, + assertValidSignal, + assertValidTimeout, + assertValidUrl, + assertValidValidateStatus, + assertValidWithCredentials +} from '../asserts'; +import { mergeHeaders, mergeParams } from '../utilities'; + +type TBuilderConfigPatch = Partial>; + +function cloneConfig(config: IHttpRequestConfig): IHttpRequestConfig { + return { + ...config, + ...(config.headers === undefined ? {} : { headers: { ...config.headers } }), + ...(config.params === undefined ? {} : { params: mergeParams(config.params) }) + }; +} + +/** + * Builds request configs immutably. Every configuration method returns a new builder instance. + */ +export class HttpRequestBuilder { + private readonly executor: THttpRequestExecutor; + private config: IHttpRequestConfig; + + constructor(executor: THttpRequestExecutor, method: THttpMethod, url: string) { + if (typeof executor !== 'function') { + throw new RequestBuilderError('Executor must be a function', REQUEST_BUILDER_ERROR_CODES.INVALID_EXECUTOR); + } + + assertValidMethod(method); + assertValidUrl(url); + + this.executor = executor; + this.config = { method, url }; + } + + private withConfig(partial: TBuilderConfigPatch): HttpRequestBuilder { + const builder = new HttpRequestBuilder(this.executor, this.config.method, this.config.url); + builder.config = cloneConfig({ ...this.config, ...partial }); + + return builder; + } + + public build(): Readonly { + assertValidRequestConfig(this.config); + + return cloneConfig(this.config); + } + + public baseUrl(value: string): HttpRequestBuilder { + assertValidBaseUrl(value); + + return this.withConfig({ baseUrl: value }); + } + + public header(key: string, value: string): HttpRequestBuilder { + assertValidHeader(key, value); + + return this.withConfig({ headers: mergeHeaders(this.config.headers, { [key]: value }) }); + } + + public headers(headers: THttpHeaders): HttpRequestBuilder { + assertValidHeaders(headers); + + return this.withConfig({ headers: mergeHeaders(this.config.headers, headers) }); + } + + public param(key: string, value: THttpParamValue): HttpRequestBuilder { + assertValidParam(key, value); + + return this.withConfig({ + params: mergeParams(this.config.params, { [key]: value }) + }); + } + + public params(params: THttpParams): HttpRequestBuilder { + assertValidParams(params); + + return this.withConfig({ + params: mergeParams(this.config.params, params) + }); + } + + public body(data: unknown): HttpRequestBuilder { + assertValidBody(this.config.method, data); + + return this.withConfig({ data }); + } + + public signal(signal: AbortSignal): HttpRequestBuilder { + assertValidSignal(signal); + + return this.withConfig({ signal }); + } + + /** A zero timeout disables the request timeout, including a timeout inherited from the client config. */ + public timeout(timeout: number): HttpRequestBuilder { + assertValidTimeout(timeout); + + return this.withConfig({ timeout }); + } + + /** Determines whether a response status should be treated as successful. */ + public validateStatus( + validateStatus: NonNullable + ): HttpRequestBuilder { + assertValidValidateStatus(validateStatus); + + return this.withConfig({ validateStatus }); + } + + /** Includes credentials in cross-origin requests. */ + public withCredentials(withCredentials: boolean): HttpRequestBuilder { + assertValidWithCredentials(withCredentials); + + return this.withConfig({ withCredentials }); + } + + public bearer(token: string): HttpRequestBuilder { + assertNonBlankString( + token, + 'Bearer token must be a non-empty string', + REQUEST_BUILDER_ERROR_CODES.INVALID_BEARER_TOKEN + ); + + if (HEADER_VALUE_LINE_BREAK_PATTERN.test(token)) { + throw new RequestBuilderError( + 'Bearer token must not contain line breaks', + REQUEST_BUILDER_ERROR_CODES.INVALID_BEARER_TOKEN + ); + } + + return this.header('Authorization', `Bearer ${token}`); + } + + public asJson(): HttpRequestBuilder { + return this.withConfig({ responseType: HTTP_RESPONSE_TYPES.JSON }); + } + + public asText(): HttpRequestBuilder { + return this.withConfig({ responseType: HTTP_RESPONSE_TYPES.TEXT }); + } + + public asBlob(): HttpRequestBuilder { + return this.withConfig({ responseType: HTTP_RESPONSE_TYPES.BLOB }); + } + + public asArrayBuffer(): HttpRequestBuilder { + return this.withConfig({ responseType: HTTP_RESPONSE_TYPES.ARRAY_BUFFER }); + } + + public asStream(): HttpRequestBuilder> { + return this.withConfig>({ responseType: HTTP_RESPONSE_TYPES.STREAM }); + } + + public execute(): Promise> { + return this.executor(this.build()); + } +} diff --git a/services/http-client/src/core/index.ts b/services/http-client/src/core/index.ts new file mode 100644 index 00000000..a61ca095 --- /dev/null +++ b/services/http-client/src/core/index.ts @@ -0,0 +1,2 @@ +export { HttpClient } from './HttpClient'; +export { HttpRequestBuilder } from './HttpRequestBuilder'; diff --git a/services/http-client/src/errors/AbortError.ts b/services/http-client/src/errors/AbortError.ts new file mode 100644 index 00000000..848b51f5 --- /dev/null +++ b/services/http-client/src/errors/AbortError.ts @@ -0,0 +1,7 @@ +import { HttpClientError } from './HttpClientError'; + +export class AbortError extends HttpClientError {} + +export function isAbortError(error: unknown): error is AbortError { + return error instanceof AbortError; +} diff --git a/services/http-client/src/errors/HttpClientError.ts b/services/http-client/src/errors/HttpClientError.ts new file mode 100644 index 00000000..39edaa29 --- /dev/null +++ b/services/http-client/src/errors/HttpClientError.ts @@ -0,0 +1,22 @@ +import { IHttpRequestConfig } from '../types'; + +export interface IHttpClientErrorOptions extends ErrorOptions { + config?: IHttpRequestConfig; +} + +/** Base class for all HTTP client errors. Supports catching any client error with a single instanceof check. */ +export class HttpClientError extends Error { + public readonly config?: IHttpRequestConfig; + + constructor(message: string, options?: IHttpClientErrorOptions) { + super(message, options); + this.name = this.constructor.name; + this.config = options?.config; + + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isHttpClientError(error: unknown): error is HttpClientError { + return error instanceof HttpClientError; +} diff --git a/services/http-client/src/errors/HttpResponseError.ts b/services/http-client/src/errors/HttpResponseError.ts new file mode 100644 index 00000000..7c0961cf --- /dev/null +++ b/services/http-client/src/errors/HttpResponseError.ts @@ -0,0 +1,33 @@ +import { IHttpRequestConfig, THttpHeaders, THttpStatusCode } from '../types'; +import { HttpClientError } from './HttpClientError'; + +/** Indicates that the server completed the request with a non-2xx status. */ +export class HttpResponseError extends HttpClientError { + public readonly status: THttpStatusCode; + public readonly statusText: string; + public readonly headers: THttpHeaders; + public readonly config: IHttpRequestConfig; + public readonly data: T | undefined; + + constructor( + message: string, + status: THttpStatusCode, + statusText: string, + headers: THttpHeaders, + config: IHttpRequestConfig, + data?: T, + options?: ErrorOptions + ) { + super(message, { ...options, config }); + + this.status = status; + this.statusText = statusText; + this.headers = headers; + this.config = config; + this.data = data; + } +} + +export function isHttpResponseError(error: unknown): error is HttpResponseError { + return error instanceof HttpResponseError; +} diff --git a/services/http-client/src/errors/NetworkError.ts b/services/http-client/src/errors/NetworkError.ts new file mode 100644 index 00000000..a18f0f3f --- /dev/null +++ b/services/http-client/src/errors/NetworkError.ts @@ -0,0 +1,7 @@ +import { HttpClientError } from './HttpClientError'; + +export class NetworkError extends HttpClientError {} + +export function isNetworkError(error: unknown): error is NetworkError { + return error instanceof NetworkError; +} diff --git a/services/http-client/src/errors/ParseError.ts b/services/http-client/src/errors/ParseError.ts new file mode 100644 index 00000000..111942d2 --- /dev/null +++ b/services/http-client/src/errors/ParseError.ts @@ -0,0 +1,24 @@ +import { THttpResponseType } from '../types'; +import { HttpClientError, IHttpClientErrorOptions } from './HttpClientError'; + +export interface IParseErrorOptions extends IHttpClientErrorOptions { + responseType?: THttpResponseType; + raw?: string; +} + +/** Indicates that a successful response could not be parsed in the requested format. */ +export class ParseError extends HttpClientError { + public readonly responseType?: THttpResponseType; + public readonly raw?: string; + + constructor(message: string, options?: IParseErrorOptions) { + super(message, options); + + this.responseType = options?.responseType; + this.raw = options?.raw; + } +} + +export function isParseError(error: unknown): error is ParseError { + return error instanceof ParseError; +} diff --git a/services/http-client/src/errors/RequestBuilderError.ts b/services/http-client/src/errors/RequestBuilderError.ts new file mode 100644 index 00000000..b0ecc069 --- /dev/null +++ b/services/http-client/src/errors/RequestBuilderError.ts @@ -0,0 +1,17 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { TRequestBuilderErrorCode } from '../types'; +import { HttpClientError } from './HttpClientError'; + +export class RequestBuilderError extends HttpClientError { + public readonly code: TRequestBuilderErrorCode; + + constructor(message: string, code: TRequestBuilderErrorCode = REQUEST_BUILDER_ERROR_CODES.INVALID_CONFIG) { + super(message); + + this.code = code; + } +} + +export function isRequestBuilderError(error: unknown): error is RequestBuilderError { + return error instanceof RequestBuilderError; +} diff --git a/services/http-client/src/errors/RequestPreparationError.ts b/services/http-client/src/errors/RequestPreparationError.ts new file mode 100644 index 00000000..edeaaa45 --- /dev/null +++ b/services/http-client/src/errors/RequestPreparationError.ts @@ -0,0 +1,8 @@ +import { HttpClientError } from './HttpClientError'; + +/** Indicates that an adapter could not prepare a request before sending it. */ +export class RequestPreparationError extends HttpClientError {} + +export function isRequestPreparationError(error: unknown): error is RequestPreparationError { + return error instanceof RequestPreparationError; +} diff --git a/services/http-client/src/errors/TimeoutError.ts b/services/http-client/src/errors/TimeoutError.ts new file mode 100644 index 00000000..cf04e8d1 --- /dev/null +++ b/services/http-client/src/errors/TimeoutError.ts @@ -0,0 +1,20 @@ +import { HttpClientError, IHttpClientErrorOptions } from './HttpClientError'; + +export interface ITimeoutErrorOptions extends IHttpClientErrorOptions { + timeout?: number; +} + +/** Indicates that the request exceeded the configured timeout. */ +export class TimeoutError extends HttpClientError { + public readonly timeout?: number; + + constructor(message: string, options?: ITimeoutErrorOptions) { + super(message, options); + + this.timeout = options?.timeout; + } +} + +export function isTimeoutError(error: unknown): error is TimeoutError { + return error instanceof TimeoutError; +} diff --git a/services/http-client/src/errors/index.ts b/services/http-client/src/errors/index.ts new file mode 100644 index 00000000..b7ab4235 --- /dev/null +++ b/services/http-client/src/errors/index.ts @@ -0,0 +1,11 @@ +export { AbortError, isAbortError } from './AbortError'; +export { HttpClientError, isHttpClientError } from './HttpClientError'; +export type { IHttpClientErrorOptions } from './HttpClientError'; +export { HttpResponseError, isHttpResponseError } from './HttpResponseError'; +export { NetworkError, isNetworkError } from './NetworkError'; +export { ParseError, isParseError } from './ParseError'; +export type { IParseErrorOptions } from './ParseError'; +export { RequestBuilderError, isRequestBuilderError } from './RequestBuilderError'; +export { RequestPreparationError, isRequestPreparationError } from './RequestPreparationError'; +export { TimeoutError, isTimeoutError } from './TimeoutError'; +export type { ITimeoutErrorOptions } from './TimeoutError'; diff --git a/services/http-client/src/index.ts b/services/http-client/src/index.ts new file mode 100644 index 00000000..43663699 --- /dev/null +++ b/services/http-client/src/index.ts @@ -0,0 +1,5 @@ +export { HttpClient, HttpRequestBuilder } from './core'; +export { FetchAdapter, XhrAdapter } from './adapters'; +export * from './constants'; +export * from './errors'; +export * from './types'; diff --git a/services/http-client/src/types/FetchAdapterOptions.ts b/services/http-client/src/types/FetchAdapterOptions.ts new file mode 100644 index 00000000..1531b3cd --- /dev/null +++ b/services/http-client/src/types/FetchAdapterOptions.ts @@ -0,0 +1,14 @@ +/** Request modes that can be explicitly set for a programmatic Fetch request. */ +export type TFetchAdapterMode = Exclude; + +/** Settings that are supported only by the Fetch transport. */ +export interface IFetchAdapterOptions { + cache?: RequestCache; + credentials?: RequestCredentials; + integrity?: string; + keepalive?: boolean; + mode?: TFetchAdapterMode; + redirect?: RequestRedirect; + referrer?: string; + referrerPolicy?: ReferrerPolicy; +} diff --git a/services/http-client/src/types/HttpClientAdapter.ts b/services/http-client/src/types/HttpClientAdapter.ts new file mode 100644 index 00000000..c62fef3d --- /dev/null +++ b/services/http-client/src/types/HttpClientAdapter.ts @@ -0,0 +1,6 @@ +import { IHttpRequestConfig } from './HttpRequestConfig'; +import { IHttpResponse } from './HttpResponse'; + +export interface IHttpClientAdapter { + request(config: IHttpRequestConfig): Promise>; +} diff --git a/services/http-client/src/types/HttpClientOptions.ts b/services/http-client/src/types/HttpClientOptions.ts new file mode 100644 index 00000000..ec4aeab4 --- /dev/null +++ b/services/http-client/src/types/HttpClientOptions.ts @@ -0,0 +1,22 @@ +import { IHttpClientAdapter } from './HttpClientAdapter'; +import { THttpHeaders } from './HttpHeaders'; +import { THttpRequestErrorHook, THttpRequestHook, THttpResponseErrorHook, THttpResponseHook } from './HttpHooks'; +import { THttpParams } from './HttpParams'; +import { TValidateStatus } from './ValidateStatus'; + +export interface IHttpClientOptions { + /** Uses FetchAdapter when omitted. */ + adapter?: IHttpClientAdapter; + baseUrl?: string; + headers?: THttpHeaders; + params?: THttpParams; + timeout?: number; + /** Determines whether a response status should be treated as successful. */ + validateStatus?: TValidateStatus; + /** Sends cookies and HTTP credentials with cross-origin requests. */ + withCredentials?: boolean; + onRequest?: THttpRequestHook; + onRequestError?: THttpRequestErrorHook; + onResponse?: THttpResponseHook; + onResponseError?: THttpResponseErrorHook; +} diff --git a/services/http-client/src/types/HttpHeaders.ts b/services/http-client/src/types/HttpHeaders.ts new file mode 100644 index 00000000..805414b5 --- /dev/null +++ b/services/http-client/src/types/HttpHeaders.ts @@ -0,0 +1 @@ +export type THttpHeaders = Record; diff --git a/services/http-client/src/types/HttpHooks.ts b/services/http-client/src/types/HttpHooks.ts new file mode 100644 index 00000000..f6d7cb5d --- /dev/null +++ b/services/http-client/src/types/HttpHooks.ts @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/no-invalid-void-type -- + * void is intentional: error hooks may not return a value, + * and only void accepts a function without return such as `(error) => { log(error); }`. */ +import { IHttpRequestConfig } from './HttpRequestConfig'; +import { IHttpResponse } from './HttpResponse'; + +/** + * Called once per request after merging with the client's default configuration, + * immediately before the adapter is called. + * The returned config continues through the pipeline. A thrown error is passed to onRequestError. + */ +export type THttpRequestHook = (config: IHttpRequestConfig) => IHttpRequestConfig | Promise; + +/** A config that continues the request, or nothing, in which case the original error is rethrown. */ +export type THttpRequestErrorHookResult = IHttpRequestConfig | void; + +/** Called only when onRequest throws or rejects. */ +export type THttpRequestErrorHook = ( + error: unknown +) => THttpRequestErrorHookResult | Promise; + +/** + * Called once per request when the adapter resolves successfully (2xx). + * The returned response is passed to the caller. A thrown error is passed to onResponseError. + */ +export type THttpResponseHook = (response: IHttpResponse) => IHttpResponse | Promise; + +/** A recovery response (onResponse is not called for it again), or nothing, in which case the original error is rethrown. */ +export type THttpResponseErrorHookResult = IHttpResponse | void; + +/** + * Called when the adapter rejects or when onResponse throws. + */ +export type THttpResponseErrorHook = ( + error: unknown +) => THttpResponseErrorHookResult | Promise; diff --git a/services/http-client/src/types/HttpMethod.ts b/services/http-client/src/types/HttpMethod.ts new file mode 100644 index 00000000..0b83fbe2 --- /dev/null +++ b/services/http-client/src/types/HttpMethod.ts @@ -0,0 +1,3 @@ +import { HTTP_METHODS } from '../constants'; + +export type THttpMethod = (typeof HTTP_METHODS)[keyof typeof HTTP_METHODS]; diff --git a/services/http-client/src/types/HttpParams.ts b/services/http-client/src/types/HttpParams.ts new file mode 100644 index 00000000..a8240c6d --- /dev/null +++ b/services/http-client/src/types/HttpParams.ts @@ -0,0 +1,6 @@ +export type THttpParamPrimitive = string | number | boolean; + +/** A single query value or a list. `null` / `undefined` omit the key (or list item) when the request is sent. */ +export type THttpParamValue = THttpParamPrimitive | null | undefined | Array; + +export type THttpParams = Record; diff --git a/services/http-client/src/types/HttpRequestBody.ts b/services/http-client/src/types/HttpRequestBody.ts new file mode 100644 index 00000000..f6db3574 --- /dev/null +++ b/services/http-client/src/types/HttpRequestBody.ts @@ -0,0 +1 @@ +export type THttpRequestBody = string | Blob | ArrayBuffer | ArrayBufferView | FormData | URLSearchParams; diff --git a/services/http-client/src/types/HttpRequestConfig.ts b/services/http-client/src/types/HttpRequestConfig.ts new file mode 100644 index 00000000..58d43d25 --- /dev/null +++ b/services/http-client/src/types/HttpRequestConfig.ts @@ -0,0 +1,19 @@ +import { THttpMethod } from './HttpMethod'; +import { THttpHeaders } from './HttpHeaders'; +import { THttpParams } from './HttpParams'; +import { THttpResponseType } from './HttpResponseType'; +import { TValidateStatus } from './ValidateStatus'; + +export interface IHttpRequestConfig { + readonly url: string; + readonly method: THttpMethod; + readonly baseUrl?: string; + readonly headers?: THttpHeaders; + readonly params?: THttpParams; + readonly data?: TData; + readonly signal?: AbortSignal; + readonly timeout?: number; + readonly validateStatus?: TValidateStatus; + readonly withCredentials?: boolean; + readonly responseType?: THttpResponseType; +} diff --git a/services/http-client/src/types/HttpRequestExecutor.ts b/services/http-client/src/types/HttpRequestExecutor.ts new file mode 100644 index 00000000..4eb82eb8 --- /dev/null +++ b/services/http-client/src/types/HttpRequestExecutor.ts @@ -0,0 +1,4 @@ +import { IHttpRequestConfig } from './HttpRequestConfig'; +import { IHttpResponse } from './HttpResponse'; + +export type THttpRequestExecutor = (config: IHttpRequestConfig) => Promise>; diff --git a/services/http-client/src/types/HttpResponse.ts b/services/http-client/src/types/HttpResponse.ts new file mode 100644 index 00000000..d33edd20 --- /dev/null +++ b/services/http-client/src/types/HttpResponse.ts @@ -0,0 +1,11 @@ +import { THttpStatusCode } from './HttpStatusCode'; +import { THttpHeaders } from './HttpHeaders'; +import { IHttpRequestConfig } from './HttpRequestConfig'; + +export interface IHttpResponse { + data?: T; + status: THttpStatusCode; + statusText: string; + headers: THttpHeaders; + config: IHttpRequestConfig; +} diff --git a/services/http-client/src/types/HttpResponseType.ts b/services/http-client/src/types/HttpResponseType.ts new file mode 100644 index 00000000..7324ae38 --- /dev/null +++ b/services/http-client/src/types/HttpResponseType.ts @@ -0,0 +1,3 @@ +import { HTTP_RESPONSE_TYPES } from '../constants'; + +export type THttpResponseType = (typeof HTTP_RESPONSE_TYPES)[keyof typeof HTTP_RESPONSE_TYPES]; diff --git a/services/http-client/src/types/HttpStatusCode.ts b/services/http-client/src/types/HttpStatusCode.ts new file mode 100644 index 00000000..5bf8a884 --- /dev/null +++ b/services/http-client/src/types/HttpStatusCode.ts @@ -0,0 +1,2 @@ +/** Any valid HTTP response status, including non-standard codes returned by proxies or servers. */ +export type THttpStatusCode = number; diff --git a/services/http-client/src/types/RequestBuilderErrorCode.ts b/services/http-client/src/types/RequestBuilderErrorCode.ts new file mode 100644 index 00000000..d0e002c8 --- /dev/null +++ b/services/http-client/src/types/RequestBuilderErrorCode.ts @@ -0,0 +1,3 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; + +export type TRequestBuilderErrorCode = (typeof REQUEST_BUILDER_ERROR_CODES)[keyof typeof REQUEST_BUILDER_ERROR_CODES]; diff --git a/services/http-client/src/types/ValidateStatus.ts b/services/http-client/src/types/ValidateStatus.ts new file mode 100644 index 00000000..3ccccb2b --- /dev/null +++ b/services/http-client/src/types/ValidateStatus.ts @@ -0,0 +1,2 @@ +/** Determines whether an HTTP response status should be treated as successful. */ +export type TValidateStatus = (status: number) => boolean; diff --git a/services/http-client/src/types/XhrAdapterOptions.ts b/services/http-client/src/types/XhrAdapterOptions.ts new file mode 100644 index 00000000..1b7d665d --- /dev/null +++ b/services/http-client/src/types/XhrAdapterOptions.ts @@ -0,0 +1,12 @@ +import { THttpResponseType } from './HttpResponseType'; +import { IHttpRequestConfig } from './HttpRequestConfig'; + +/** Settings that are supported only by the XMLHttpRequest transport. */ +export interface IXhrAdapterOptions { + mimeType?: string; + responseType?: THttpResponseType; + timeout?: number; + withCredentials?: boolean; + onDownloadProgress?: (event: ProgressEvent, config: Readonly) => void; + onUploadProgress?: (event: ProgressEvent, config: Readonly) => void; +} diff --git a/services/http-client/src/types/index.ts b/services/http-client/src/types/index.ts new file mode 100644 index 00000000..0a1daea3 --- /dev/null +++ b/services/http-client/src/types/index.ts @@ -0,0 +1,16 @@ +export * from './HttpMethod'; +export * from './HttpStatusCode'; +export * from './HttpClientAdapter'; +export * from './HttpClientOptions'; +export * from './FetchAdapterOptions'; +export * from './HttpRequestConfig'; +export * from './HttpRequestBody'; +export * from './HttpRequestExecutor'; +export * from './HttpResponse'; +export * from './HttpHeaders'; +export * from './HttpHooks'; +export * from './HttpParams'; +export * from './HttpResponseType'; +export * from './RequestBuilderErrorCode'; +export * from './XhrAdapterOptions'; +export * from './ValidateStatus'; diff --git a/services/http-client/src/utilities/buildUrl.ts b/services/http-client/src/utilities/buildUrl.ts new file mode 100644 index 00000000..3952e67f --- /dev/null +++ b/services/http-client/src/utilities/buildUrl.ts @@ -0,0 +1,126 @@ +import { THttpParams } from '../types'; + +interface IUrlParts { + path: string; + query: string; + hash: string; +} + +function splitUrl(value: string): IUrlParts { + const hashIndex = value.indexOf('#'); + const beforeHash = hashIndex === -1 ? value : value.slice(0, hashIndex); + const hash = hashIndex === -1 ? '' : value.slice(hashIndex); + const queryIndex = beforeHash.indexOf('?'); + + return { + path: queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex), + query: queryIndex === -1 ? '' : beforeHash.slice(queryIndex + 1), + hash + }; +} + +function joinPaths(basePath: string, path: string): string { + if (!path) { + return basePath; + } + + if (!basePath) { + return path; + } + + return `${basePath.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; +} + +function getAbsoluteUrl(value: string): URL | undefined { + try { + return new URL(value); + } catch { + return undefined; + } +} + +function serializeParams(params: THttpParams | undefined): string { + if (params === undefined) { + return ''; + } + + const searchParams = new URLSearchParams(); + + for (const [key, value] of Object.entries(params)) { + if (value === null || value === undefined) { + continue; + } + + const values = Array.isArray(value) ? value : [value]; + + for (const item of values) { + if (item === null || item === undefined) { + continue; + } + + searchParams.append(key, String(item)); + } + } + + return searchParams.toString(); +} + +function appendParams(url: string, params: THttpParams | undefined): string { + const query = serializeParams(params); + + if (!query) { + return url; + } + + const { path, query: existingQuery, hash } = splitUrl(url); + const combinedQuery = [existingQuery, query].filter(Boolean).join('&'); + + return `${path}?${combinedQuery}${hash}`; +} + +function resolveRelativeUrl(baseUrl: string, url: string): string { + const base = splitUrl(baseUrl); + const request = splitUrl(url); + const query = [base.query, request.query].filter(Boolean).join('&'); + + return `${joinPaths(base.path, request.path)}${query ? `?${query}` : ''}${request.hash}`; +} + +function resolveAbsoluteUrl(base: URL, url: string): string { + const request = splitUrl(url); + + base.pathname = joinPaths(base.pathname, request.path); + base.hash = request.hash; + + if (request.query) { + const requestParams = new URLSearchParams(request.query); + + requestParams.forEach((value, key) => { + base.searchParams.append(key, value); + }); + } + + return base.toString(); +} + +/** + * Resolves a request URL against an optional base URL, preserves existing query values and fragments, + * and appends serialized params before the fragment. + */ +export function buildUrl(baseUrl: string | undefined, url: string, params?: THttpParams): string { + const absoluteUrl = getAbsoluteUrl(url); + + if (absoluteUrl !== undefined) { + return appendParams(absoluteUrl.toString(), params); + } + + if (baseUrl === undefined) { + return appendParams(url, params); + } + + const absoluteBaseUrl = getAbsoluteUrl(baseUrl); + const fullUrl = + absoluteBaseUrl === undefined ? resolveRelativeUrl(baseUrl, url) : resolveAbsoluteUrl(absoluteBaseUrl, url); + + return appendParams(fullUrl, params); +} diff --git a/services/http-client/src/utilities/getErrorMessage.ts b/services/http-client/src/utilities/getErrorMessage.ts new file mode 100644 index 00000000..1885479a --- /dev/null +++ b/services/http-client/src/utilities/getErrorMessage.ts @@ -0,0 +1,4 @@ +/** Returns a non-empty Error message or the provided fallback for unknown error values. */ +export function getErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback; +} diff --git a/services/http-client/src/utilities/index.ts b/services/http-client/src/utilities/index.ts new file mode 100644 index 00000000..66635d9c --- /dev/null +++ b/services/http-client/src/utilities/index.ts @@ -0,0 +1,6 @@ +export { buildUrl } from './buildUrl'; +export { getErrorMessage } from './getErrorMessage'; +export { hasHeader, mergeHeaders } from './mergeHeaders'; +export { mergeParams } from './mergeParams'; +export { prepareRequestBody } from './prepareRequestBody'; +export { isStatusAccepted } from './isStatusAccepted'; diff --git a/services/http-client/src/utilities/isStatusAccepted.ts b/services/http-client/src/utilities/isStatusAccepted.ts new file mode 100644 index 00000000..7c02b946 --- /dev/null +++ b/services/http-client/src/utilities/isStatusAccepted.ts @@ -0,0 +1,6 @@ +import { TValidateStatus } from '../types'; + +/** Applies a custom status predicate or accepts the standard successful HTTP status range. */ +export function isStatusAccepted(status: number, validateStatus?: TValidateStatus): boolean { + return validateStatus === undefined ? status >= 200 && status < 300 : validateStatus(status); +} diff --git a/services/http-client/src/utilities/mergeHeaders.ts b/services/http-client/src/utilities/mergeHeaders.ts new file mode 100644 index 00000000..4fb4e014 --- /dev/null +++ b/services/http-client/src/utilities/mergeHeaders.ts @@ -0,0 +1,34 @@ +import { THttpHeaders } from '../types'; + +/** Merges header maps from left to right using case-insensitive names while preserving the latest name casing. */ +export function mergeHeaders(...sources: Array): THttpHeaders { + const result: THttpHeaders = {}; + const normalizedKeys = new Map(); + + for (const source of sources) { + if (source === undefined) { + continue; + } + + for (const [key, value] of Object.entries(source)) { + const normalizedKey = key.toLowerCase(); + const previousKey = normalizedKeys.get(normalizedKey); + + if (previousKey !== undefined) { + delete result[previousKey]; + } + + result[key] = value; + normalizedKeys.set(normalizedKey, key); + } + } + + return result; +} + +/** Checks whether a header map contains a name using case-insensitive comparison. */ +export function hasHeader(headers: THttpHeaders, name: string): boolean { + const normalizedName = name.toLowerCase(); + + return Object.keys(headers).some(key => key.toLowerCase() === normalizedName); +} diff --git a/services/http-client/src/utilities/mergeParams.ts b/services/http-client/src/utilities/mergeParams.ts new file mode 100644 index 00000000..748f08ac --- /dev/null +++ b/services/http-client/src/utilities/mergeParams.ts @@ -0,0 +1,42 @@ +import { THttpParamPrimitive, THttpParams } from '../types'; + +function isPresentParamPrimitive(value: unknown): value is THttpParamPrimitive { + return value !== null && value !== undefined; +} + +/** + * Merges query params from left to right. `null` / `undefined` remove a key; nullish array items are dropped. + * Returns undefined when no params remain. + */ +export function mergeParams(...sources: Array): THttpParams | undefined { + const result: THttpParams = {}; + + for (const source of sources) { + if (source === undefined) { + continue; + } + + for (const [key, value] of Object.entries(source)) { + if (value === null || value === undefined) { + delete result[key]; + continue; + } + + if (Array.isArray(value)) { + const items = value.filter(isPresentParamPrimitive); + + if (items.length === 0) { + delete result[key]; + } else { + result[key] = [...items]; + } + + continue; + } + + result[key] = value; + } + } + + return Object.keys(result).length === 0 ? undefined : result; +} diff --git a/services/http-client/src/utilities/prepareRequestBody.ts b/services/http-client/src/utilities/prepareRequestBody.ts new file mode 100644 index 00000000..e2795df3 --- /dev/null +++ b/services/http-client/src/utilities/prepareRequestBody.ts @@ -0,0 +1,49 @@ +import { THttpHeaders, THttpRequestBody } from '../types'; +import { hasHeader } from './mergeHeaders'; + +function hasTag(value: unknown, tag: string): boolean { + return Object.prototype.toString.call(value) === tag; +} + +function isRequestBody(value: unknown): value is THttpRequestBody { + return ( + typeof value === 'string' || + ArrayBuffer.isView(value) || + hasTag(value, '[object ArrayBuffer]') || + hasTag(value, '[object Blob]') || + hasTag(value, '[object FormData]') || + hasTag(value, '[object URLSearchParams]') + ); +} + +function isUrlSearchParams(value: THttpRequestBody): value is URLSearchParams { + return hasTag(value, '[object URLSearchParams]'); +} + +/** + * Converts request data into a transport-compatible body. + * Mutates the provided headers by adding a default Content-Type for JSON and URLSearchParams bodies when absent. + * FormData is returned without setting Content-Type so the transport can add its boundary. + * + * @throws TypeError when data cannot be serialized as JSON. + */ +export function prepareRequestBody(data: unknown, headers: THttpHeaders): THttpRequestBody { + if (isRequestBody(data)) { + if (isUrlSearchParams(data) && !hasHeader(headers, 'Content-Type')) { + headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8'; + } + + return isUrlSearchParams(data) ? data.toString() : data; + } + + if (!hasHeader(headers, 'Content-Type')) { + headers['Content-Type'] = 'application/json'; + } + + const body: unknown = JSON.stringify(data); + if (typeof body !== 'string') { + throw new TypeError('Request body cannot be serialized as JSON'); + } + + return body; +} diff --git a/services/http-client/test/__fixtures__/constants.ts b/services/http-client/test/__fixtures__/constants.ts new file mode 100644 index 00000000..8a4f5db5 --- /dev/null +++ b/services/http-client/test/__fixtures__/constants.ts @@ -0,0 +1 @@ +export const BASE_URL = 'https://api.test.com'; diff --git a/services/http-client/test/__fixtures__/index.ts b/services/http-client/test/__fixtures__/index.ts new file mode 100644 index 00000000..c94f80f8 --- /dev/null +++ b/services/http-client/test/__fixtures__/index.ts @@ -0,0 +1 @@ +export * from './constants'; diff --git a/services/http-client/test/__handlers__/HttpClient.DELETE.handlers.ts b/services/http-client/test/__handlers__/HttpClient.DELETE.handlers.ts new file mode 100644 index 00000000..043fa399 --- /dev/null +++ b/services/http-client/test/__handlers__/HttpClient.DELETE.handlers.ts @@ -0,0 +1,8 @@ +import { http, HttpResponse } from 'msw'; +import { BASE_URL } from '../__fixtures__'; + +export const handlers = [ + http.delete(`${BASE_URL}/items/1`, () => { + return new HttpResponse(null, { status: 204 }); + }) +]; diff --git a/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts new file mode 100644 index 00000000..703d9dcb --- /dev/null +++ b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts @@ -0,0 +1,73 @@ +import { http, HttpResponse } from 'msw'; +import { BASE_URL } from '../__fixtures__'; + +export const handlers = [ + http.get(`${BASE_URL}/users/1`, () => { + return HttpResponse.json({ id: 1, name: 'John' }); + }), + + http.get(`${BASE_URL}/users`, ({ request }) => { + const url = new URL(request.url); + return HttpResponse.json({ + page: url.searchParams.get('page'), + role: url.searchParams.getAll('role'), + active: url.searchParams.get('active'), + value: url.searchParams.getAll('value'), + source: url.searchParams.get('source'), + keys: [...url.searchParams.keys()] + }); + }), + + http.get(`${BASE_URL}/api/users`, () => { + return HttpResponse.json({ scoped: true }); + }), + + http.get(`${BASE_URL}/headers`, ({ request }) => { + return HttpResponse.json({ + auth: request.headers.get('authorization'), + custom: request.headers.get('x-custom') + }); + }), + + http.get(`${BASE_URL}/text`, () => { + return new HttpResponse('hello world', { headers: { 'Content-Type': 'text/plain' } }); + }), + + http.get(`${BASE_URL}/empty`, () => { + return new HttpResponse(null, { headers: { 'Content-Length': '0' } }); + }), + + http.get(`${BASE_URL}/binary`, () => { + const bytes = new Uint8Array([1, 2, 3, 4]); + return new HttpResponse(bytes.buffer, { headers: { 'Content-Type': 'application/octet-stream' } }); + }), + + http.get(`${BASE_URL}/stream`, () => { + return new HttpResponse('stream response', { headers: { 'Content-Type': 'text/plain' } }); + }), + + http.get(`${BASE_URL}/not-found`, () => { + return HttpResponse.json( + { error: 'Not found' }, + { status: 404, statusText: 'Not Found', headers: { 'X-Request-Id': 'request-1' } } + ); + }), + + http.get(`${BASE_URL}/bad-request`, () => { + return HttpResponse.json( + { message: 'Validation failed', errors: { name: ['Required'] } }, + { status: 400, statusText: 'Bad Request' } + ); + }), + + http.get(`${BASE_URL}/unprocessable-entity`, () => { + return HttpResponse.json( + { message: 'Validation failed', errors: { name: ['Required'] } }, + { status: 422, statusText: 'Unprocessable Entity' } + ); + }), + + http.get(`${BASE_URL}/invalid-json`, () => { + return new HttpResponse('{ invalid json', { headers: { 'Content-Type': 'application/json' } }); + }) +]; diff --git a/services/http-client/test/__handlers__/HttpClient.HEAD.handlers.ts b/services/http-client/test/__handlers__/HttpClient.HEAD.handlers.ts new file mode 100644 index 00000000..2547c26e --- /dev/null +++ b/services/http-client/test/__handlers__/HttpClient.HEAD.handlers.ts @@ -0,0 +1,8 @@ +import { http, HttpResponse } from 'msw'; +import { BASE_URL } from '../__fixtures__'; + +export const handlers = [ + http.head(`${BASE_URL}/items`, () => { + return new HttpResponse(null, { status: 200, headers: { 'X-Total': '42' } }); + }) +]; diff --git a/services/http-client/test/__handlers__/HttpClient.OPTIONS.handlers.ts b/services/http-client/test/__handlers__/HttpClient.OPTIONS.handlers.ts new file mode 100644 index 00000000..65ff5211 --- /dev/null +++ b/services/http-client/test/__handlers__/HttpClient.OPTIONS.handlers.ts @@ -0,0 +1,11 @@ +import { http, HttpResponse } from 'msw'; +import { BASE_URL } from '../__fixtures__'; + +export const handlers = [ + http.options(`${BASE_URL}/items`, () => { + return new HttpResponse(null, { + status: 204, + headers: { Allow: 'GET, POST, HEAD, OPTIONS' } + }); + }) +]; diff --git a/services/http-client/test/__handlers__/HttpClient.PATCH.handlers.ts b/services/http-client/test/__handlers__/HttpClient.PATCH.handlers.ts new file mode 100644 index 00000000..5281c252 --- /dev/null +++ b/services/http-client/test/__handlers__/HttpClient.PATCH.handlers.ts @@ -0,0 +1,9 @@ +import { http, HttpResponse } from 'msw'; +import { BASE_URL } from '../__fixtures__'; + +export const handlers = [ + http.patch(`${BASE_URL}/items/1`, async ({ request }) => { + const body = (await request.json()) as Record; + return HttpResponse.json({ patched: true, ...body }); + }) +]; diff --git a/services/http-client/test/__handlers__/HttpClient.POST.handlers.ts b/services/http-client/test/__handlers__/HttpClient.POST.handlers.ts new file mode 100644 index 00000000..6d1fb52f --- /dev/null +++ b/services/http-client/test/__handlers__/HttpClient.POST.handlers.ts @@ -0,0 +1,46 @@ +import { http, HttpResponse } from 'msw'; +import { BASE_URL } from '../__fixtures__'; + +export const handlers = [ + http.post(`${BASE_URL}/items`, async ({ request }) => { + const contentType = request.headers.get('content-type'); + const body = (await request.json()) as Record; + return HttpResponse.json({ received: body, contentType }); + }), + + http.post(`${BASE_URL}/raw`, async ({ request }) => { + const contentType = request.headers.get('content-type'); + const body = await request.text(); + return HttpResponse.json({ received: body, contentType }); + }), + + http.post(`${BASE_URL}/form-data`, async ({ request }) => { + const contentType = request.headers.get('content-type'); + const body = await request.formData(); + return HttpResponse.json({ + name: body.get('name'), + roles: body.getAll('role'), + contentType + }); + }), + + http.post(`${BASE_URL}/url-search-params`, async ({ request }) => { + const contentType = request.headers.get('content-type'); + const body = new URLSearchParams(await request.text()); + return HttpResponse.json({ + name: body.get('name'), + roles: body.getAll('role'), + contentType + }); + }), + + http.post(`${BASE_URL}/binary`, async ({ request }) => { + const body = new Uint8Array(await request.arrayBuffer()); + return HttpResponse.json({ received: Array.from(body) }); + }), + + http.post(`${BASE_URL}/echo`, async ({ request }) => { + const body = (await request.json()) as Record; + return HttpResponse.json({ created: true, ...body }); + }) +]; diff --git a/services/http-client/test/__handlers__/HttpClient.PUT.handlers.ts b/services/http-client/test/__handlers__/HttpClient.PUT.handlers.ts new file mode 100644 index 00000000..64a40050 --- /dev/null +++ b/services/http-client/test/__handlers__/HttpClient.PUT.handlers.ts @@ -0,0 +1,9 @@ +import { http, HttpResponse } from 'msw'; +import { BASE_URL } from '../__fixtures__'; + +export const handlers = [ + http.put(`${BASE_URL}/items/1`, async ({ request }) => { + const body = (await request.json()) as Record; + return HttpResponse.json({ updated: true, ...body }); + }) +]; diff --git a/services/http-client/test/__handlers__/HttpClient.defaultConfig.handlers.ts b/services/http-client/test/__handlers__/HttpClient.defaultConfig.handlers.ts new file mode 100644 index 00000000..cd146b73 --- /dev/null +++ b/services/http-client/test/__handlers__/HttpClient.defaultConfig.handlers.ts @@ -0,0 +1,29 @@ +import { http, HttpResponse } from 'msw'; +import { BASE_URL } from '../__fixtures__'; + +export const handlers = [ + http.get(`${BASE_URL}/test`, ({ request }) => { + const url = new URL(request.url); + + return HttpResponse.json({ + def: request.headers.get('x-default'), + custom: request.headers.get('x-custom'), + authorization: request.headers.get('authorization'), + locale: url.searchParams.get('locale'), + page: url.searchParams.get('page'), + role: url.searchParams.getAll('role') + }); + }), + + http.get(`${BASE_URL}/base-test`, () => { + return HttpResponse.json({ ok: true }); + }), + + http.get(`${BASE_URL}/server-error`, () => { + return HttpResponse.json({ error: 'Internal error' }, { status: 500 }); + }), + + http.get(`${BASE_URL}/network-error`, () => { + return HttpResponse.error(); + }) +]; diff --git a/services/http-client/test/__handlers__/HttpClient.hooks.handlers.ts b/services/http-client/test/__handlers__/HttpClient.hooks.handlers.ts new file mode 100644 index 00000000..467a1acb --- /dev/null +++ b/services/http-client/test/__handlers__/HttpClient.hooks.handlers.ts @@ -0,0 +1,23 @@ +import { http, HttpResponse } from 'msw'; +import { BASE_URL } from '../__fixtures__'; + +export const handlers = [ + http.get(`${BASE_URL}/items`, () => { + return HttpResponse.json({ id: 1, name: 'John' }); + }), + + http.get(`${BASE_URL}/echo-headers`, ({ request }) => { + return HttpResponse.json({ + authorization: request.headers.get('authorization'), + custom: request.headers.get('x-custom') + }); + }), + + http.get(`${BASE_URL}/not-found`, () => { + return HttpResponse.json({ error: 'Not found' }, { status: 404 }); + }), + + http.get(`${BASE_URL}/network-error`, () => { + return HttpResponse.error(); + }) +]; diff --git a/services/http-client/test/adapters/AdapterOptions.tests.ts b/services/http-client/test/adapters/AdapterOptions.tests.ts new file mode 100644 index 00000000..d8fc16d9 --- /dev/null +++ b/services/http-client/test/adapters/AdapterOptions.tests.ts @@ -0,0 +1,374 @@ +import { FetchAdapter } from '../../src/adapters/FetchAdapter'; +import { XhrAdapter } from '../../src/adapters/XhrAdapter'; +import { HTTP_RESPONSE_TYPES, HTTP_STATUS_CODES, REQUEST_BUILDER_ERROR_CODES } from '../../src/constants'; +import { HttpClient } from '../../src/core/HttpClient'; +import { RequestBuilderError } from '../../src/errors'; +import { IFetchAdapterOptions, IXhrAdapterOptions, TRequestBuilderErrorCode } from '../../src/types'; + +function createMockXhr(response: unknown = 'response'): { + xhr: XMLHttpRequest; + overrideMimeType: ReturnType; + setReadyState(value: number): void; + setResponseText(value: string): void; +} { + const overrideMimeType = vi.fn(); + const state = { + status: HTTP_STATUS_CODES.OK, + statusText: 'OK', + response, + responseText: typeof response === 'string' ? response : '', + responseType: '', + readyState: XMLHttpRequest.DONE, + timeout: 0, + withCredentials: false, + upload: { onprogress: null }, + onprogress: null, + onreadystatechange: null, + onload: null, + onerror: null, + onabort: null, + ontimeout: null, + open: vi.fn(), + overrideMimeType, + setRequestHeader: vi.fn(), + send: vi.fn(), + abort: vi.fn(), + getAllResponseHeaders: vi.fn(() => ''), + getResponseHeader: vi.fn(() => null) + }; + const xhr = state as unknown as XMLHttpRequest; + + xhr.send = vi.fn(() => { + xhr.onload?.(new ProgressEvent('load')); + }); + + return { + xhr, + overrideMimeType, + setReadyState(value) { + state.readyState = value; + }, + setResponseText(value) { + state.responseText = value; + } + }; +} + +function expectRequestBuilderError(action: () => unknown, code: TRequestBuilderErrorCode): void { + try { + action(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(RequestBuilderError); + expect(error).toMatchObject({ code }); + } +} + +describe('adapter constructor options', () => { + test('passes Fetch adapter options to fetch', async () => { + const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: HTTP_STATUS_CODES.NO_CONTENT }))); + vi.stubGlobal('fetch', fetchMock); + + try { + const adapter = new FetchAdapter({ + cache: 'no-store', + credentials: 'omit', + integrity: 'sha256-test', + keepalive: true, + mode: 'cors', + redirect: 'error', + referrer: 'https://app.example.test', + referrerPolicy: 'no-referrer' + }); + + await new HttpClient({ adapter }).get('https://api.example.test/items').execute(); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.test/items', + expect.objectContaining({ + cache: 'no-store', + credentials: 'omit', + integrity: 'sha256-test', + keepalive: true, + mode: 'cors', + redirect: 'error', + referrer: 'https://app.example.test', + referrerPolicy: 'no-referrer' + }) + ); + } finally { + vi.unstubAllGlobals(); + } + }); + + test('request credentials override Fetch adapter credentials', async () => { + const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: HTTP_STATUS_CODES.NO_CONTENT }))); + vi.stubGlobal('fetch', fetchMock); + + try { + const adapter = new FetchAdapter({ credentials: 'omit' }); + + await new HttpClient({ adapter, withCredentials: true }).get('https://api.example.test/items').execute(); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.test/items', + expect.objectContaining({ credentials: 'include' }) + ); + } finally { + vi.unstubAllGlobals(); + } + }); + + test('accepts only-if-cached with same-origin mode', () => { + expect(() => new FetchAdapter({ cache: 'only-if-cached', mode: 'same-origin' })).not.toThrow(); + }); + + test('uses XHR adapter defaults when the request does not set them', async () => { + const mockXhr = createMockXhr(); + vi.stubGlobal( + 'XMLHttpRequest', + vi.fn(() => mockXhr.xhr) + ); + + try { + const adapter = new XhrAdapter({ + mimeType: 'application/json', + responseType: HTTP_RESPONSE_TYPES.TEXT, + timeout: 1000, + withCredentials: true + }); + + const response = await new HttpClient({ adapter }).get('https://api.example.test/items').execute(); + + expect(mockXhr.xhr.withCredentials).toBe(true); + expect(mockXhr.xhr.timeout).toBe(1000); + expect(mockXhr.xhr.responseType).toBe('text'); + expect(mockXhr.overrideMimeType).toHaveBeenCalledWith('application/json'); + expect(response.config).toMatchObject({ + responseType: HTTP_RESPONSE_TYPES.TEXT, + timeout: 1000, + withCredentials: true + }); + } finally { + vi.unstubAllGlobals(); + } + }); + + test('request configuration overrides XHR adapter defaults', async () => { + const mockXhr = createMockXhr(new ArrayBuffer(0)); + vi.stubGlobal( + 'XMLHttpRequest', + vi.fn(() => mockXhr.xhr) + ); + + try { + const adapter = new XhrAdapter({ + responseType: HTTP_RESPONSE_TYPES.TEXT, + timeout: 1000, + withCredentials: true + }); + + const response = await new HttpClient({ adapter }) + .get('https://api.example.test/items') + .asArrayBuffer() + .timeout(50) + .withCredentials(false) + .execute(); + + expect(mockXhr.xhr.withCredentials).toBe(false); + expect(mockXhr.xhr.timeout).toBe(50); + expect(mockXhr.xhr.responseType).toBe('arraybuffer'); + expect(response.config).toMatchObject({ + responseType: HTTP_RESPONSE_TYPES.ARRAY_BUFFER, + timeout: 50, + withCredentials: false + }); + } finally { + vi.unstubAllGlobals(); + } + }); + + test('reports XHR upload and download progress with the resolved request config', async () => { + const mockXhr = createMockXhr(); + const onDownloadProgress = vi.fn(); + const onUploadProgress = vi.fn(); + const uploadEvent = new ProgressEvent('progress', { + lengthComputable: true, + loaded: 25, + total: 100 + }); + const downloadEvent = new ProgressEvent('progress', { + lengthComputable: true, + loaded: 50, + total: 100 + }); + vi.stubGlobal( + 'XMLHttpRequest', + vi.fn(() => mockXhr.xhr) + ); + mockXhr.xhr.send = vi.fn(() => { + mockXhr.xhr.upload.onprogress?.call(mockXhr.xhr, uploadEvent); + mockXhr.xhr.onprogress?.call(mockXhr.xhr, downloadEvent); + mockXhr.xhr.onload?.call(mockXhr.xhr, new ProgressEvent('load')); + }); + + try { + const adapter = new XhrAdapter({ onDownloadProgress, onUploadProgress }); + + await new HttpClient({ adapter, timeout: 1000 }) + .post('https://api.example.test/items') + .body('payload') + .asText() + .execute(); + + expect(onUploadProgress).toHaveBeenCalledWith( + uploadEvent, + expect.objectContaining({ + method: 'POST', + url: 'https://api.example.test/items', + data: 'payload', + responseType: HTTP_RESPONSE_TYPES.TEXT, + timeout: 1000 + }) + ); + expect(onDownloadProgress).toHaveBeenCalledWith( + downloadEvent, + expect.objectContaining({ + method: 'POST', + url: 'https://api.example.test/items', + data: 'payload', + responseType: HTTP_RESPONSE_TYPES.TEXT, + timeout: 1000 + }) + ); + } finally { + vi.unstubAllGlobals(); + } + }); + + test('does not attach an upload progress listener to a request without a body', async () => { + const mockXhr = createMockXhr(); + vi.stubGlobal( + 'XMLHttpRequest', + vi.fn(() => mockXhr.xhr) + ); + + try { + const adapter = new XhrAdapter({ onUploadProgress: vi.fn() }); + + await new HttpClient({ adapter }).get('https://api.example.test/items').asText().execute(); + + expect(mockXhr.xhr.upload.onprogress).toBeNull(); + } finally { + vi.unstubAllGlobals(); + } + }); + + test('does not attach an upload progress listener when the callback is omitted', async () => { + const mockXhr = createMockXhr(); + vi.stubGlobal( + 'XMLHttpRequest', + vi.fn(() => mockXhr.xhr) + ); + + try { + await new HttpClient({ adapter: new XhrAdapter() }) + .post('https://api.example.test/items') + .body('payload') + .asText() + .execute(); + + expect(mockXhr.xhr.upload.onprogress).toBeNull(); + } finally { + vi.unstubAllGlobals(); + } + }); + + test('reports download progress while preserving the XHR stream response', async () => { + const mockXhr = createMockXhr(''); + const onDownloadProgress = vi.fn(); + const downloadEvent = new ProgressEvent('progress', { + lengthComputable: true, + loaded: 6, + total: 6 + }); + vi.stubGlobal( + 'XMLHttpRequest', + vi.fn(() => mockXhr.xhr) + ); + mockXhr.xhr.send = vi.fn(() => { + mockXhr.setReadyState(XMLHttpRequest.HEADERS_RECEIVED); + mockXhr.xhr.onreadystatechange?.call(mockXhr.xhr, new Event('readystatechange')); + mockXhr.setResponseText('stream'); + mockXhr.xhr.onprogress?.call(mockXhr.xhr, downloadEvent); + mockXhr.setReadyState(XMLHttpRequest.DONE); + mockXhr.xhr.onload?.call(mockXhr.xhr, new ProgressEvent('load')); + }); + + try { + const response = await new HttpClient({ adapter: new XhrAdapter({ onDownloadProgress }) }) + .get('https://api.example.test/items') + .asStream() + .execute(); + + if (response.data === undefined) { + throw new Error('Expected stream response data'); + } + + expect(onDownloadProgress).toHaveBeenCalledWith(downloadEvent, response.config); + expect(await new Response(response.data).text()).toBe('stream'); + } finally { + vi.unstubAllGlobals(); + } + }); + + test.each([ + { + name: 'Fetch options object', + action: () => new FetchAdapter(null as unknown as Record), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_FETCH_ADAPTER_OPTIONS + }, + { + name: 'unsupported Fetch mode', + action: () => new FetchAdapter({ mode: 'unsupported' } as unknown as IFetchAdapterOptions), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_FETCH_ADAPTER_OPTIONS + }, + { + name: 'navigate Fetch mode', + action: () => new FetchAdapter({ mode: 'navigate' } as unknown as IFetchAdapterOptions), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_FETCH_ADAPTER_OPTIONS + }, + { + name: 'only-if-cached Fetch cache without same-origin mode', + action: () => new FetchAdapter({ cache: 'only-if-cached' }), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_FETCH_ADAPTER_OPTIONS + }, + { + name: 'XHR mime type', + action: () => new XhrAdapter({ mimeType: ' ' }), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_XHR_ADAPTER_OPTIONS + }, + { + name: 'XHR timeout', + action: () => new XhrAdapter({ timeout: -1 }), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_TIMEOUT + }, + { + name: 'XHR response type', + action: () => new XhrAdapter({ responseType: 'invalid' } as unknown as IXhrAdapterOptions), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_RESPONSE_TYPE + }, + { + name: 'XHR download progress callback', + action: () => new XhrAdapter({ onDownloadProgress: true } as unknown as IXhrAdapterOptions), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_XHR_ADAPTER_OPTIONS + }, + { + name: 'XHR upload progress callback', + action: () => new XhrAdapter({ onUploadProgress: true } as unknown as IXhrAdapterOptions), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_XHR_ADAPTER_OPTIONS + } + ])('rejects invalid $name', ({ action, code }) => { + expectRequestBuilderError(action, code); + }); +}); diff --git a/services/http-client/test/core/HttpClient.DELETE.tests.ts b/services/http-client/test/core/HttpClient.DELETE.tests.ts new file mode 100644 index 00000000..9375973a --- /dev/null +++ b/services/http-client/test/core/HttpClient.DELETE.tests.ts @@ -0,0 +1,38 @@ +import { setupServer } from 'msw/node'; +import { HttpClient } from '../../src/core/HttpClient'; +import { FetchAdapter } from '../../src/adapters/FetchAdapter'; +import { XhrAdapter } from '../../src/adapters/XhrAdapter'; +import { HTTP_STATUS_CODES } from '../../src/constants'; +import { IHttpClientAdapter } from '../../src/types'; +import { handlers } from '../__handlers__/HttpClient.DELETE.handlers'; +import { BASE_URL } from '../__fixtures__'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +beforeEach(() => server.use(...handlers)); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const adapters: Array<{ name: string; create: () => IHttpClientAdapter }> = [ + { name: 'FetchAdapter', create: () => new FetchAdapter() }, + { name: 'XhrAdapter', create: () => new XhrAdapter() } +]; + +describe.each(adapters)('HttpClient.$name — DELETE', ({ create }) => { + function createClient(): HttpClient { + return new HttpClient({ + adapter: create(), + baseUrl: BASE_URL, + headers: { 'X-Default': 'default-header' } + }); + } + + test('returns 204 with undefined data', async () => { + const client = createClient(); + const response = await client.delete('/items/1').asText().execute(); + + expect(response.status).toBe(HTTP_STATUS_CODES.NO_CONTENT); + expect(response.data).toBeUndefined(); + }); +}); diff --git a/services/http-client/test/core/HttpClient.GET.tests.ts b/services/http-client/test/core/HttpClient.GET.tests.ts new file mode 100644 index 00000000..fd92f331 --- /dev/null +++ b/services/http-client/test/core/HttpClient.GET.tests.ts @@ -0,0 +1,297 @@ +import { setupServer } from 'msw/node'; +import { HttpClient } from '../../src/core/HttpClient'; +import { FetchAdapter } from '../../src/adapters/FetchAdapter'; +import { XhrAdapter } from '../../src/adapters/XhrAdapter'; +import { HTTP_RESPONSE_TYPES, HTTP_STATUS_CODES } from '../../src/constants'; +import { HttpResponseError, isHttpResponseError, ParseError } from '../../src/errors'; +import { IHttpClientAdapter } from '../../src/types'; +import { handlers } from '../__handlers__/HttpClient.GET.handlers'; +import { BASE_URL } from '../__fixtures__'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +beforeEach(() => server.use(...handlers)); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const adapters: Array<{ name: string; create: () => IHttpClientAdapter }> = [ + { name: 'FetchAdapter', create: () => new FetchAdapter() }, + { name: 'XhrAdapter', create: () => new XhrAdapter() } +]; + +interface IValidationError { + message: string; + errors: Record; +} + +function isValidationError(data: unknown): data is IValidationError { + if (typeof data !== 'object' || data === null) { + return false; + } + + const { message, errors } = data as Record; + + return typeof message === 'string' && typeof errors === 'object' && errors !== null; +} + +describe.each(adapters)('HttpClient.$name — GET', ({ name, create }) => { + function createClient(): HttpClient { + return new HttpClient({ + adapter: create(), + baseUrl: BASE_URL, + headers: { 'X-Default': 'default-header' } + }); + } + + test('returns parsed JSON with status and statusText', async () => { + const client = createClient(); + const response = await client.get('/users/1').asJson<{ id: number; name: string }>().execute(); + + expect(response.data).toEqual({ id: 1, name: 'John' }); + expect(response.status).toBe(HTTP_STATUS_CODES.OK); + expect(response.statusText).toBe('OK'); + }); + + test('sends query params including arrays', async () => { + const client = createClient(); + const response = await client + .get('/users') + .params({ page: '2', role: ['admin', 'user'] }) + .asJson<{ page: string | null; role: string[] }>() + .execute(); + + expect(response.data?.page).toBe('2'); + expect(response.data?.role).toEqual(['admin', 'user']); + }); + + test('sends numeric and boolean query params', async () => { + const client = createClient(); + const response = await client + .get('/users') + .params({ page: 2, active: true, value: [0, false, 'all'] }) + .asJson<{ page: string | null; active: string | null; value: string[] }>() + .execute(); + + expect(response.data).toMatchObject({ + page: '2', + active: 'true', + value: ['0', 'false', 'all'] + }); + }); + + test('omits null and undefined query params', async () => { + const client = createClient(); + const response = await client + .get('/users') + .params({ page: 2, active: null, source: undefined, value: [1, null, undefined, 2] }) + .asJson<{ + page: string | null; + active: string | null; + source: string | null; + value: string[]; + keys: string[]; + }>() + .execute(); + + expect(response.data).toMatchObject({ + page: '2', + active: null, + source: null, + value: ['1', '2'] + }); + expect(response.data?.keys).toEqual(['page', 'value', 'value']); + }); + + test('preserves an existing query and appends params before a fragment', async () => { + const client = createClient(); + const response = await client + .get('/users?source=existing#fragment') + .param('page', '2') + .asJson<{ page: string | null; source: string | null }>() + .execute(); + + expect(response.data?.source).toBe('existing'); + expect(response.data?.page).toBe('2'); + }); + + test('joins baseUrl and request paths without duplicate or missing slashes', async () => { + const client = new HttpClient({ adapter: create(), baseUrl: `${BASE_URL}/api/` }); + const response = await client.get('/users').asJson<{ scoped: boolean }>().execute(); + + expect(response.data?.scoped).toBe(true); + }); + + test('sends custom headers', async () => { + const client = createClient(); + const response = await client + .get('/headers') + .header('Authorization', 'Bearer token') + .header('X-Custom', 'value') + .asJson<{ auth: string | null; custom: string | null }>() + .execute(); + + expect(response.data?.auth).toBe('Bearer token'); + expect(response.data?.custom).toBe('value'); + }); + + test('overrides request headers case-insensitively', async () => { + const client = createClient(); + const response = await client + .get('/headers') + .header('Authorization', 'Bearer first') + .header('authorization', 'Bearer second') + .asJson<{ auth: string | null }>() + .execute(); + + expect(response.data?.auth).toBe('Bearer second'); + }); + + test('returns text when responseType is text', async () => { + const client = createClient(); + const response = await client.get('/text').asText().execute(); + + expect(response.data).toBe('hello world'); + }); + + test('returns undefined for an empty response with Content-Length 0', async () => { + const client = createClient(); + const response = await client.get('/empty').execute(); + + expect(response.data).toBeUndefined(); + }); + + test('returns ArrayBuffer when responseType is arrayBuffer', async () => { + const client = createClient(); + const response = await client.get('/binary').asArrayBuffer().execute(); + + expect(response.data).toBeInstanceOf(ArrayBuffer); + expect(Array.from(new Uint8Array(response.data as ArrayBuffer))).toEqual([1, 2, 3, 4]); + }); + + test('returns Blob when responseType is blob', async () => { + const client = createClient(); + const response = await client.get('/binary').asBlob().execute(); + + if (response.data === undefined) { + throw new Error('Expected Blob response data'); + } + + expect(response.data).toMatchObject({ size: 4, type: 'application/octet-stream' }); + }); + + test('returns a readable stream when responseType is stream', async () => { + const client = createClient(); + const response = await client.get('/stream').asStream().execute(); + + if (response.data === undefined) { + throw new Error('Expected stream response data'); + } + + expect(response.data).toBeInstanceOf(ReadableStream); + expect(await new Response(response.data).text()).toBe('stream response'); + }); + + test('throws ParseError when Fetch stream body is null', async () => { + if (name !== 'FetchAdapter') { + return; + } + + const client = createClient(); + + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + body: null + } as unknown as Response) + ) + ); + + try { + await client.get('/stream').asStream().execute(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ParseError); + const parseError = error as ParseError; + + expect(parseError.responseType).toBe(HTTP_RESPONSE_TYPES.STREAM); + expect(parseError.config).toMatchObject({ url: '/stream', baseUrl: BASE_URL }); + } finally { + vi.unstubAllGlobals(); + } + }); + + test('throws HttpResponseError with response context and data on 404', async () => { + const client = createClient(); + + try { + await client.get('/not-found').execute(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(HttpResponseError); + const responseError = error as HttpResponseError; + + expect(responseError.status).toBe(HTTP_STATUS_CODES.NOT_FOUND); + expect(responseError.statusText).toBe('Not Found'); + expect(responseError.headers['x-request-id']).toBe('request-1'); + expect(responseError.config).toMatchObject({ method: 'GET', url: '/not-found', baseUrl: BASE_URL }); + expect(responseError.data).toEqual({ error: 'Not found' }); + } + }); + + test.each([ + { path: '/bad-request', status: HTTP_STATUS_CODES.BAD_REQUEST }, + { path: '/unprocessable-entity', status: HTTP_STATUS_CODES.UNPROCESSABLE_ENTITY } + ])('validates typed error data for status $status', async ({ path, status }) => { + const client = createClient(); + + try { + await client.get(path).execute(); + expect.fail('Should have thrown'); + } catch (error) { + if (!isHttpResponseError(error) || !isValidationError(error.data)) { + throw error; + } + + expect(error.status).toBe(status); + expect(error.data).toEqual({ + message: 'Validation failed', + errors: { name: ['Required'] } + }); + } + }); + + test('parses error data for a blob response', async () => { + const client = createClient(); + + try { + await client.get('/not-found').asBlob().execute(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(HttpResponseError); + expect((error as HttpResponseError).data).toEqual({ error: 'Not found' }); + } + }); + + test('throws ParseError with its cause for malformed JSON', async () => { + const client = createClient(); + + try { + await client.get('/invalid-json').execute(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ParseError); + const parseError = error as ParseError; + + expect(parseError.cause).toBeInstanceOf(SyntaxError); + expect(parseError.config).toMatchObject({ url: '/invalid-json', baseUrl: BASE_URL }); + expect(parseError.responseType).toBe('json'); + expect(parseError.raw).toBe('{ invalid json'); + } + }); +}); diff --git a/services/http-client/test/core/HttpClient.HEAD.tests.ts b/services/http-client/test/core/HttpClient.HEAD.tests.ts new file mode 100644 index 00000000..d3c9b823 --- /dev/null +++ b/services/http-client/test/core/HttpClient.HEAD.tests.ts @@ -0,0 +1,38 @@ +import { setupServer } from 'msw/node'; +import { HttpClient } from '../../src/core/HttpClient'; +import { FetchAdapter } from '../../src/adapters/FetchAdapter'; +import { XhrAdapter } from '../../src/adapters/XhrAdapter'; +import { HTTP_STATUS_CODES } from '../../src/constants'; +import { IHttpClientAdapter } from '../../src/types'; +import { handlers } from '../__handlers__/HttpClient.HEAD.handlers'; +import { BASE_URL } from '../__fixtures__'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +beforeEach(() => server.use(...handlers)); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const adapters: Array<{ name: string; create: () => IHttpClientAdapter }> = [ + { name: 'FetchAdapter', create: () => new FetchAdapter() }, + { name: 'XhrAdapter', create: () => new XhrAdapter() } +]; + +describe.each(adapters)('HttpClient.$name — HEAD', ({ create }) => { + function createClient(): HttpClient { + return new HttpClient({ + adapter: create(), + baseUrl: BASE_URL, + headers: { 'X-Default': 'default-header' } + }); + } + + test('returns headers without body', async () => { + const client = createClient(); + const response = await client.head('/items').asText().execute(); + + expect(response.status).toBe(HTTP_STATUS_CODES.OK); + expect(response.headers['x-total']).toBe('42'); + }); +}); diff --git a/services/http-client/test/core/HttpClient.OPTIONS.tests.ts b/services/http-client/test/core/HttpClient.OPTIONS.tests.ts new file mode 100644 index 00000000..54161ef1 --- /dev/null +++ b/services/http-client/test/core/HttpClient.OPTIONS.tests.ts @@ -0,0 +1,38 @@ +import { setupServer } from 'msw/node'; +import { HttpClient } from '../../src/core/HttpClient'; +import { FetchAdapter } from '../../src/adapters/FetchAdapter'; +import { XhrAdapter } from '../../src/adapters/XhrAdapter'; +import { HTTP_STATUS_CODES } from '../../src/constants'; +import { IHttpClientAdapter } from '../../src/types'; +import { handlers } from '../__handlers__/HttpClient.OPTIONS.handlers'; +import { BASE_URL } from '../__fixtures__'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +beforeEach(() => server.use(...handlers)); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const adapters: Array<{ name: string; create: () => IHttpClientAdapter }> = [ + { name: 'FetchAdapter', create: () => new FetchAdapter() }, + { name: 'XhrAdapter', create: () => new XhrAdapter() } +]; + +describe.each(adapters)('HttpClient.$name — OPTIONS', ({ create }) => { + function createClient(): HttpClient { + return new HttpClient({ + adapter: create(), + baseUrl: BASE_URL, + headers: { 'X-Default': 'default-header' } + }); + } + + test('returns Allow header', async () => { + const client = createClient(); + const response = await client.options('/items').asText().execute(); + + expect(response.status).toBe(HTTP_STATUS_CODES.NO_CONTENT); + expect(response.headers['allow']).toBe('GET, POST, HEAD, OPTIONS'); + }); +}); diff --git a/services/http-client/test/core/HttpClient.PATCH.tests.ts b/services/http-client/test/core/HttpClient.PATCH.tests.ts new file mode 100644 index 00000000..ee8a5877 --- /dev/null +++ b/services/http-client/test/core/HttpClient.PATCH.tests.ts @@ -0,0 +1,40 @@ +import { setupServer } from 'msw/node'; +import { HttpClient } from '../../src/core/HttpClient'; +import { FetchAdapter } from '../../src/adapters/FetchAdapter'; +import { XhrAdapter } from '../../src/adapters/XhrAdapter'; +import { IHttpClientAdapter } from '../../src/types'; +import { handlers } from '../__handlers__/HttpClient.PATCH.handlers'; +import { BASE_URL } from '../__fixtures__'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +beforeEach(() => server.use(...handlers)); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const adapters: Array<{ name: string; create: () => IHttpClientAdapter }> = [ + { name: 'FetchAdapter', create: () => new FetchAdapter() }, + { name: 'XhrAdapter', create: () => new XhrAdapter() } +]; + +describe.each(adapters)('HttpClient.$name — PATCH', ({ create }) => { + function createClient(): HttpClient { + return new HttpClient({ + adapter: create(), + baseUrl: BASE_URL, + headers: { 'X-Default': 'default-header' } + }); + } + + test('sends JSON body and receives patched data', async () => { + const client = createClient(); + const response = await client + .patch('/items/1') + .body({ name: 'Patched' }) + .asJson<{ patched: boolean; name: string }>() + .execute(); + + expect(response.data).toEqual({ patched: true, name: 'Patched' }); + }); +}); diff --git a/services/http-client/test/core/HttpClient.POST.tests.ts b/services/http-client/test/core/HttpClient.POST.tests.ts new file mode 100644 index 00000000..56a1a416 --- /dev/null +++ b/services/http-client/test/core/HttpClient.POST.tests.ts @@ -0,0 +1,152 @@ +import { setupServer } from 'msw/node'; +import { Blob as NodeBlob } from 'node:buffer'; +import { HttpClient } from '../../src/core/HttpClient'; +import { FetchAdapter } from '../../src/adapters/FetchAdapter'; +import { XhrAdapter } from '../../src/adapters/XhrAdapter'; +import { IHttpClientAdapter } from '../../src/types'; +import { handlers } from '../__handlers__/HttpClient.POST.handlers'; +import { BASE_URL } from '../__fixtures__'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +beforeEach(() => server.use(...handlers)); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const adapters: Array<{ name: string; create: () => IHttpClientAdapter }> = [ + { name: 'FetchAdapter', create: () => new FetchAdapter() }, + { name: 'XhrAdapter', create: () => new XhrAdapter() } +]; + +describe.each(adapters)('HttpClient.$name — POST', ({ create }) => { + function createClient(): HttpClient { + return new HttpClient({ + adapter: create(), + baseUrl: BASE_URL, + headers: { 'X-Default': 'default-header' } + }); + } + + test('sends JSON body with auto Content-Type', async () => { + const client = createClient(); + const response = await client + .post('/items') + .body({ name: 'New item' }) + .asJson<{ received: { name: string }; contentType: string | null }>() + .execute(); + + expect(response.data?.received).toEqual({ name: 'New item' }); + expect(response.data?.contentType).toBe('application/json'); + }); + + test('sends string body without overriding Content-Type', async () => { + const client = createClient(); + const response = await client + .post('/raw') + .body('plain text body') + .header('Content-Type', 'text/plain') + .asJson<{ received: string; contentType: string | null }>() + .execute(); + + expect(response.data?.received).toBe('plain text body'); + expect(response.data?.contentType).toBe('text/plain'); + }); + + test('sends null as an explicit JSON body', async () => { + const client = createClient(); + const response = await client + .post('/raw') + .body(null) + .asJson<{ received: string; contentType: string | null }>() + .execute(); + + expect(response.data).toEqual({ received: 'null', contentType: 'application/json' }); + }); + + test('sends FormData without JSON serialization', async () => { + const client = createClient(); + const formData = new FormData(); + formData.append('name', 'Jane'); + formData.append('role', 'admin'); + formData.append('role', 'editor'); + + const response = await client + .post('/form-data') + .body(formData) + .asJson<{ name: string; roles: string[]; contentType: string | null }>() + .execute(); + + expect(response.data?.name).toBe('Jane'); + expect(response.data?.roles).toEqual(['admin', 'editor']); + expect(response.data?.contentType).toMatch(/^multipart\/form-data; boundary=/); + }); + + test('sends URLSearchParams with form URL encoded Content-Type', async () => { + const client = createClient(); + const params = new URLSearchParams(); + params.append('name', 'Jane'); + params.append('role', 'admin'); + params.append('role', 'editor'); + + const response = await client + .post('/url-search-params') + .body(params) + .asJson<{ name: string; roles: string[]; contentType: string | null }>() + .execute(); + + expect(response.data).toEqual({ + name: 'Jane', + roles: ['admin', 'editor'], + contentType: 'application/x-www-form-urlencoded;charset=UTF-8' + }); + }); + + test('sends Blob body without JSON serialization', async () => { + const client = createClient(); + const response = await client + .post('/binary') + .body(new NodeBlob([new Uint8Array([0, 1, 255])])) + .asJson<{ received: number[] }>() + .execute(); + + expect(response.data?.received).toEqual([0, 1, 255]); + }); + + test('sends typed array body without JSON serialization', async () => { + const client = createClient(); + const response = await client + .post('/binary') + .body(new Uint8Array([0, 1, 255])) + .asJson<{ received: number[] }>() + .execute(); + + expect(response.data?.received).toEqual([0, 1, 255]); + }); + + test('does not add a duplicate Content-Type when it uses different casing', async () => { + const client = new HttpClient({ + adapter: create(), + baseUrl: BASE_URL, + headers: { 'content-type': 'application/vnd.api+json' } + }); + const response = await client + .post('/items') + .body({ name: 'New item' }) + .asJson<{ contentType: string | null }>() + .execute(); + + expect(response.data?.contentType).toBe('application/vnd.api+json'); + }); + + test('echoes body back', async () => { + const client = createClient(); + const response = await client + .post('/echo') + .body({ name: 'Jane' }) + .asJson<{ created: boolean; name: string }>() + .execute(); + + expect(response.data).toEqual({ created: true, name: 'Jane' }); + }); +}); diff --git a/services/http-client/test/core/HttpClient.PUT.tests.ts b/services/http-client/test/core/HttpClient.PUT.tests.ts new file mode 100644 index 00000000..e2a8f392 --- /dev/null +++ b/services/http-client/test/core/HttpClient.PUT.tests.ts @@ -0,0 +1,40 @@ +import { setupServer } from 'msw/node'; +import { HttpClient } from '../../src/core/HttpClient'; +import { FetchAdapter } from '../../src/adapters/FetchAdapter'; +import { XhrAdapter } from '../../src/adapters/XhrAdapter'; +import { IHttpClientAdapter } from '../../src/types'; +import { handlers } from '../__handlers__/HttpClient.PUT.handlers'; +import { BASE_URL } from '../__fixtures__'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +beforeEach(() => server.use(...handlers)); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const adapters: Array<{ name: string; create: () => IHttpClientAdapter }> = [ + { name: 'FetchAdapter', create: () => new FetchAdapter() }, + { name: 'XhrAdapter', create: () => new XhrAdapter() } +]; + +describe.each(adapters)('HttpClient.$name — PUT', ({ create }) => { + function createClient(): HttpClient { + return new HttpClient({ + adapter: create(), + baseUrl: BASE_URL, + headers: { 'X-Default': 'default-header' } + }); + } + + test('sends JSON body and receives updated data', async () => { + const client = createClient(); + const response = await client + .put('/items/1') + .body({ name: 'Updated' }) + .asJson<{ updated: boolean; name: string }>() + .execute(); + + expect(response.data).toEqual({ updated: true, name: 'Updated' }); + }); +}); diff --git a/services/http-client/test/core/HttpClient.abort-timeout.tests.ts b/services/http-client/test/core/HttpClient.abort-timeout.tests.ts new file mode 100644 index 00000000..06b1a2aa --- /dev/null +++ b/services/http-client/test/core/HttpClient.abort-timeout.tests.ts @@ -0,0 +1,148 @@ +import { setupServer } from 'msw/node'; +import { HttpClient } from '../../src/core/HttpClient'; +import { FetchAdapter } from '../../src/adapters/FetchAdapter'; +import { XhrAdapter } from '../../src/adapters/XhrAdapter'; +import { AbortError, TimeoutError } from '../../src/errors'; +import { IHttpClientAdapter } from '../../src/types'; +import { BASE_URL } from '../__fixtures__'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const adapters: Array<{ name: string; create: () => IHttpClientAdapter }> = [ + { name: 'FetchAdapter', create: () => new FetchAdapter() }, + { name: 'XhrAdapter', create: () => new XhrAdapter() } +]; + +describe.each(adapters)('HttpClient.$name — abort', ({ name, create }) => { + test('throws AbortError when signal is already aborted', async () => { + const client = new HttpClient({ adapter: create(), baseUrl: BASE_URL }); + const controller = new AbortController(); + controller.abort('Cancelled by the caller'); + + try { + await client.get('/').signal(controller.signal).execute(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(AbortError); + expect((error as AbortError).config).toMatchObject({ url: '/', baseUrl: BASE_URL }); + expect((error as AbortError).cause).toBe('Cancelled by the caller'); + } + }); + + test('throws AbortError when request is aborted via signal', async () => { + const client = new HttpClient({ adapter: create(), baseUrl: BASE_URL }); + const controller = new AbortController(); + + if (name === 'FetchAdapter') { + vi.stubGlobal( + 'fetch', + vi.fn((_url: string, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('The operation was aborted', 'AbortError')); + }); + }); + }) + ); + } else { + const mockXHR: XMLHttpRequest = { + status: 0, + statusText: '', + response: '', + responseText: '', + responseType: '', + timeout: 0, + onload: null, + onerror: null, + onabort: null, + ontimeout: null, + open: vi.fn(), + setRequestHeader: vi.fn(), + send: vi.fn(), + abort: vi.fn(function (this: XMLHttpRequest) { + this.onabort?.(new ProgressEvent('abort')); + }), + getAllResponseHeaders: vi.fn(() => ''), + getResponseHeader: vi.fn(() => null) + } as unknown as XMLHttpRequest; + + vi.stubGlobal( + 'XMLHttpRequest', + vi.fn(() => mockXHR) + ); + } + + const promise = client.get('/slow').signal(controller.signal).execute(); + controller.abort(); + + try { + await promise; + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(AbortError); + } finally { + vi.unstubAllGlobals(); + } + }); +}); + +describe.each(adapters)('HttpClient.$name — timeout', ({ name, create }) => { + test('throws TimeoutError when request exceeds timeout', async () => { + const client = new HttpClient({ adapter: create(), baseUrl: BASE_URL }); + + if (name === 'FetchAdapter') { + vi.stubGlobal( + 'fetch', + vi.fn((_url: string, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('The operation was aborted', 'AbortError')); + }); + }); + }) + ); + } else { + const mockXHR: XMLHttpRequest = { + status: 0, + statusText: '', + response: '', + responseText: '', + responseType: '', + timeout: 0, + onload: null, + onerror: null, + onabort: null, + ontimeout: null, + open: vi.fn(), + setRequestHeader: vi.fn(), + send: vi.fn(function (this: XMLHttpRequest) { + setTimeout(() => this.ontimeout?.(new ProgressEvent('timeout')), 0); + }), + abort: vi.fn(), + getAllResponseHeaders: vi.fn(() => ''), + getResponseHeader: vi.fn(() => null) + } as unknown as XMLHttpRequest; + + vi.stubGlobal( + 'XMLHttpRequest', + vi.fn(() => mockXHR) + ); + } + + try { + await client.get('/slow').timeout(50).execute(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(TimeoutError); + expect((error as TimeoutError).message).toContain('50'); + expect((error as TimeoutError).timeout).toBe(50); + expect((error as TimeoutError).config).toMatchObject({ url: '/slow', baseUrl: BASE_URL }); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/services/http-client/test/core/HttpClient.constructor.tests.ts b/services/http-client/test/core/HttpClient.constructor.tests.ts new file mode 100644 index 00000000..d84af342 --- /dev/null +++ b/services/http-client/test/core/HttpClient.constructor.tests.ts @@ -0,0 +1,137 @@ +import { HttpClient } from '../../src/core/HttpClient'; +import { REQUEST_BUILDER_ERROR_CODES } from '../../src/constants'; +import { RequestBuilderError } from '../../src/errors'; +import { IHttpClientAdapter, IHttpClientOptions, TRequestBuilderErrorCode } from '../../src/types'; + +function createAdapter(): IHttpClientAdapter { + return { + request: vi.fn() + }; +} + +function createOptions(): IHttpClientOptions { + return { adapter: createAdapter() }; +} + +function expectRequestBuilderError(action: () => unknown, code: TRequestBuilderErrorCode): void { + try { + action(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(RequestBuilderError); + expect(error).toMatchObject({ code }); + } +} + +describe('HttpClient constructor', () => { + test.each([ + { + name: 'options object', + options: undefined, + code: REQUEST_BUILDER_ERROR_CODES.INVALID_CONFIG + }, + { + name: 'adapter', + options: { adapter: {} }, + code: REQUEST_BUILDER_ERROR_CODES.INVALID_ADAPTER + }, + { + name: 'base URL', + options: { ...createOptions(), baseUrl: ' ' }, + code: REQUEST_BUILDER_ERROR_CODES.INVALID_BASE_URL + }, + { + name: 'headers', + options: { ...createOptions(), headers: { 'Bad Header': 'value' } }, + code: REQUEST_BUILDER_ERROR_CODES.INVALID_HEADER + }, + { + name: 'params', + options: { ...createOptions(), params: { page: Number.NaN } }, + code: REQUEST_BUILDER_ERROR_CODES.INVALID_PARAM + }, + { + name: 'timeout', + options: { ...createOptions(), timeout: -1 }, + code: REQUEST_BUILDER_ERROR_CODES.INVALID_TIMEOUT + }, + { + name: 'validate status', + options: { ...createOptions(), validateStatus: true }, + code: REQUEST_BUILDER_ERROR_CODES.INVALID_VALIDATE_STATUS + }, + { + name: 'with credentials', + options: { ...createOptions(), withCredentials: 'true' }, + code: REQUEST_BUILDER_ERROR_CODES.INVALID_WITH_CREDENTIALS + }, + { + name: 'onRequest hook', + options: { ...createOptions(), onRequest: true }, + code: REQUEST_BUILDER_ERROR_CODES.INVALID_HOOK + }, + { + name: 'onRequestError hook', + options: { ...createOptions(), onRequestError: true }, + code: REQUEST_BUILDER_ERROR_CODES.INVALID_HOOK + }, + { + name: 'onResponse hook', + options: { ...createOptions(), onResponse: true }, + code: REQUEST_BUILDER_ERROR_CODES.INVALID_HOOK + }, + { + name: 'onResponseError hook', + options: { ...createOptions(), onResponseError: true }, + code: REQUEST_BUILDER_ERROR_CODES.INVALID_HOOK + } + ])('rejects invalid $name', ({ options, code }) => { + expectRequestBuilderError(() => new HttpClient(options as unknown as IHttpClientOptions), code); + }); + + test('accepts valid options', () => { + const options: IHttpClientOptions = { + adapter: createAdapter(), + baseUrl: 'https://example.test', + headers: { 'X-Default': 'value' }, + params: { locale: 'ru' }, + timeout: 1000, + validateStatus: status => status >= 200 && status < 400, + withCredentials: true, + onRequest: config => config, + onRequestError: () => undefined, + onResponse: response => response, + onResponseError: () => undefined + }; + + expect(() => new HttpClient(options)).not.toThrow(); + }); + + test('uses FetchAdapter when adapter is omitted', async () => { + const fetchMock = vi.fn(() => + Promise.resolve( + new Response(JSON.stringify({ ok: true }), { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' } + }) + ) + ); + vi.stubGlobal('fetch', fetchMock); + + try { + const response = await new HttpClient({}) + .get('https://example.test/health') + .asJson<{ ok: boolean }>() + .execute(); + + expect(response.data).toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://example.test/health', + expect.objectContaining({ method: 'GET' }) + ); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/services/http-client/test/core/HttpClient.credentials.tests.ts b/services/http-client/test/core/HttpClient.credentials.tests.ts new file mode 100644 index 00000000..967a53e5 --- /dev/null +++ b/services/http-client/test/core/HttpClient.credentials.tests.ts @@ -0,0 +1,97 @@ +import { FetchAdapter } from '../../src/adapters/FetchAdapter'; +import { XhrAdapter } from '../../src/adapters/XhrAdapter'; +import { HttpClient } from '../../src/core/HttpClient'; +import { HTTP_STATUS_CODES } from '../../src/constants'; +import { IHttpClientAdapter, IHttpRequestConfig, IHttpResponse } from '../../src/types'; + +function createResponse(config: IHttpRequestConfig): IHttpResponse { + return { + status: HTTP_STATUS_CODES.OK, + statusText: 'OK', + headers: {}, + config + }; +} + +function createMockXhr(): XMLHttpRequest { + const xhr = { + status: HTTP_STATUS_CODES.OK, + statusText: 'OK', + response: '{}', + responseText: '{}', + responseType: '', + timeout: 0, + withCredentials: false, + onload: null, + onerror: null, + onabort: null, + ontimeout: null, + open: vi.fn(), + setRequestHeader: vi.fn(), + send: vi.fn(), + abort: vi.fn(), + getAllResponseHeaders: vi.fn(() => ''), + getResponseHeader: vi.fn(() => null) + } as unknown as XMLHttpRequest; + + xhr.send = vi.fn(() => { + xhr.onload?.(new ProgressEvent('load')); + }); + + return xhr; +} + +describe('HttpClient credentials', () => { + test('inherits and overrides default withCredentials', async () => { + const configs: IHttpRequestConfig[] = []; + const adapter: IHttpClientAdapter = { + request(config: IHttpRequestConfig): Promise> { + configs.push(config); + + return Promise.resolve(createResponse(config)); + } + }; + const client = new HttpClient({ adapter, withCredentials: true }); + + await client.get('/items').execute(); + await client.get('/items').withCredentials(false).execute(); + + expect(configs.map(config => config.withCredentials)).toEqual([true, false]); + }); + + test('passes include credentials to Fetch', async () => { + const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: HTTP_STATUS_CODES.NO_CONTENT }))); + vi.stubGlobal('fetch', fetchMock); + + try { + await new HttpClient({ adapter: new FetchAdapter(), withCredentials: true }) + .get('https://example.test/items') + .execute(); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://example.test/items', + expect.objectContaining({ credentials: 'include' }) + ); + } finally { + vi.unstubAllGlobals(); + } + }); + + test('passes withCredentials to XHR', async () => { + const xhr = createMockXhr(); + vi.stubGlobal( + 'XMLHttpRequest', + vi.fn(() => xhr) + ); + + try { + await new HttpClient({ adapter: new XhrAdapter(), withCredentials: true }) + .get('https://example.test/items') + .execute(); + + expect(xhr.withCredentials).toBe(true); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/services/http-client/test/core/HttpClient.defaultConfig.tests.ts b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts new file mode 100644 index 00000000..66f398f4 --- /dev/null +++ b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts @@ -0,0 +1,217 @@ +import { setupServer } from 'msw/node'; +import { HttpClient } from '../../src/core/HttpClient'; +import { FetchAdapter } from '../../src/adapters/FetchAdapter'; +import { XhrAdapter } from '../../src/adapters/XhrAdapter'; +import { HTTP_STATUS_CODES } from '../../src/constants'; +import { HttpResponseError, NetworkError } from '../../src/errors'; +import { IHttpClientAdapter, IHttpRequestConfig, THttpHeaders } from '../../src/types'; +import { handlers } from '../__handlers__/HttpClient.defaultConfig.handlers'; +import { BASE_URL } from '../__fixtures__'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +beforeEach(() => server.use(...handlers)); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const adapters: Array<{ name: string; create: () => IHttpClientAdapter }> = [ + { name: 'FetchAdapter', create: () => new FetchAdapter() }, + { name: 'XhrAdapter', create: () => new XhrAdapter() } +]; + +describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { + function createClient(): HttpClient { + return new HttpClient({ + adapter: create(), + baseUrl: BASE_URL, + headers: { 'X-Default': 'default-header', Authorization: 'Bearer default' } + }); + } + + test('merges default headers with request headers', async () => { + const client = createClient(); + const response = await client + .get('/test') + .header('X-Custom', 'value') + .asJson<{ def: string | null; custom: string | null }>() + .execute(); + + expect(response.data?.def).toBe('default-header'); + expect(response.data?.custom).toBe('value'); + }); + + test('request headers override default headers', async () => { + const client = createClient(); + const response = await client + .get('/test') + .header('X-Default', 'overridden') + .asJson<{ def: string | null; custom: string | null }>() + .execute(); + + expect(response.data?.def).toBe('overridden'); + }); + + test('request headers override default headers regardless of casing', async () => { + const client = createClient(); + const response = await client + .get('/test') + .header('authorization', 'Bearer request') + .asJson<{ authorization: string | null }>() + .execute(); + + expect(response.data?.authorization).toBe('Bearer request'); + }); + + test('sends default params with every request', async () => { + const client = new HttpClient({ + adapter: create(), + baseUrl: BASE_URL, + params: { locale: 'ru', page: '1', role: ['admin', 'editor'] } + }); + + const response = await client + .get('/test') + .asJson<{ locale: string | null; page: string | null; role: string[] }>() + .execute(); + + expect(response.data).toMatchObject({ locale: 'ru', page: '1', role: ['admin', 'editor'] }); + }); + + test('request params override default params with the same key', async () => { + const client = new HttpClient({ + adapter: create(), + baseUrl: BASE_URL, + params: { locale: 'ru', page: '1', role: ['admin'] } + }); + + const response = await client + .get('/test') + .params({ page: '2', role: ['editor'] }) + .asJson<{ locale: string | null; page: string | null; role: string[] }>() + .execute(); + + expect(response.data).toMatchObject({ locale: 'ru', page: '2', role: ['editor'] }); + }); + + test.each([ + { name: 'params are absent', params: undefined }, + { name: 'only nullish params are provided', params: { page: null, role: [undefined, null] } } + ])('sets merged params to undefined when $name', async ({ params }) => { + let receivedConfig: IHttpRequestConfig | undefined; + const client = new HttpClient({ + adapter: create(), + baseUrl: BASE_URL, + params, + onRequest: config => { + receivedConfig = config; + + return config; + } + }); + + await client.get('/test').execute(); + + expect(receivedConfig?.params).toBeUndefined(); + }); + + test('copies default params when the client is created', async () => { + const params = { locale: 'ru', role: ['admin'] }; + const client = new HttpClient({ adapter: create(), baseUrl: BASE_URL, params }); + + params.locale = 'en'; + params.role.push('editor'); + + const response = await client.get('/test').asJson<{ locale: string | null; role: string[] }>().execute(); + + expect(response.data).toMatchObject({ locale: 'ru', role: ['admin'] }); + }); + + test('copies default headers when the client is created', async () => { + const headers: THttpHeaders = { 'X-Default': 'default-header' }; + const client = new HttpClient({ adapter: create(), baseUrl: BASE_URL, headers }); + + headers['X-Default'] = 'changed-after-creation'; + headers['X-Added'] = 'must-not-be-sent'; + + const response = await client.get('/test').asJson<{ def: string | null; custom: string | null }>().execute(); + + expect(response.data?.def).toBe('default-header'); + expect(response.data?.custom).toBeNull(); + }); + + test('uses default baseUrl', async () => { + const client = createClient(); + const response = await client.get('/base-test').asJson<{ ok: boolean }>().execute(); + + expect(response.data?.ok).toBe(true); + }); + + test('throws HttpResponseError on 500', async () => { + const client = createClient(); + + try { + await client.get('/server-error').execute(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(HttpResponseError); + expect((error as HttpResponseError).status).toBe(HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR); + } + }); + + test('uses client validateStatus to accept a response outside 2xx', async () => { + const validateStatus = vi.fn((status: number) => status === HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR); + const client = new HttpClient({ adapter: create(), baseUrl: BASE_URL, validateStatus }); + + const response = await client.get('/server-error').asJson<{ error: string }>().execute(); + + expect(response.status).toBe(HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR); + expect(response.data).toEqual({ error: 'Internal error' }); + expect(validateStatus).toHaveBeenCalledWith(HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR); + }); + + test('request validateStatus overrides the client predicate', async () => { + const clientValidateStatus = vi.fn(() => true); + const requestValidateStatus = vi.fn(() => false); + const client = new HttpClient({ + adapter: create(), + baseUrl: BASE_URL, + validateStatus: clientValidateStatus + }); + + await expect( + client.get('/server-error').validateStatus(requestValidateStatus).execute() + ).rejects.toBeInstanceOf(HttpResponseError); + expect(requestValidateStatus).toHaveBeenCalledWith(HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR); + expect(clientValidateStatus).not.toHaveBeenCalled(); + }); + + test('propagates an error thrown by validateStatus through onResponseError', async () => { + const validationError = new Error('Status validation failed'); + const onResponseError = vi.fn(() => undefined); + const client = new HttpClient({ + adapter: create(), + baseUrl: BASE_URL, + validateStatus: () => { + throw validationError; + }, + onResponseError + }); + + await expect(client.get('/test').execute()).rejects.toBe(validationError); + expect(onResponseError).toHaveBeenCalledOnce(); + expect(onResponseError).toHaveBeenCalledWith(validationError); + }); + + test('throws NetworkError on network failure', async () => { + const client = createClient(); + + try { + await client.get('/network-error').execute(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(NetworkError); + expect((error as NetworkError).config).toMatchObject({ url: '/network-error', baseUrl: BASE_URL }); + } + }); +}); diff --git a/services/http-client/test/core/HttpClient.hooks.tests.ts b/services/http-client/test/core/HttpClient.hooks.tests.ts new file mode 100644 index 00000000..c603184e --- /dev/null +++ b/services/http-client/test/core/HttpClient.hooks.tests.ts @@ -0,0 +1,443 @@ +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { HttpClient } from '../../src/core/HttpClient'; +import { FetchAdapter } from '../../src/adapters/FetchAdapter'; +import { XhrAdapter } from '../../src/adapters/XhrAdapter'; +import { HTTP_METHODS, HTTP_STATUS_CODES, REQUEST_BUILDER_ERROR_CODES } from '../../src/constants'; +import { + AbortError, + HttpResponseError, + NetworkError, + RequestBuilderError, + RequestPreparationError +} from '../../src/errors'; +import { IHttpClientAdapter, IHttpClientOptions, IHttpRequestConfig, IHttpResponse } from '../../src/types'; +import { handlers } from '../__handlers__/HttpClient.hooks.handlers'; +import { BASE_URL } from '../__fixtures__'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +beforeEach(() => server.use(...handlers)); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const adapters: Array<{ name: string; create: () => IHttpClientAdapter }> = [ + { name: 'FetchAdapter', create: () => new FetchAdapter() }, + { name: 'XhrAdapter', create: () => new XhrAdapter() } +]; + +describe.each(adapters)('HttpClient.$name — hooks', ({ create }) => { + function createClient(hooks: Partial = {}): HttpClient { + return new HttpClient({ adapter: create(), baseUrl: BASE_URL, ...hooks }); + } + + test('onRequest receives the merged config and its result reaches the adapter', async () => { + let receivedConfig: IHttpRequestConfig | undefined; + const client = new HttpClient({ + adapter: create(), + baseUrl: BASE_URL, + headers: { 'X-Default': 'default-header' }, + timeout: 5000, + onRequest: config => { + receivedConfig = config; + + return { ...config, headers: { ...config.headers, Authorization: 'Bearer token' } }; + } + }); + + const response = await client + .get('/echo-headers') + .header('X-Custom', 'value') + .asJson<{ authorization: string | null }>() + .execute(); + + expect(receivedConfig?.baseUrl).toBe(BASE_URL); + expect(receivedConfig?.timeout).toBe(5000); + expect(receivedConfig?.headers).toMatchObject({ 'X-Default': 'default-header', 'X-Custom': 'value' }); + expect(response.data?.authorization).toBe('Bearer token'); + }); + + test('supports an async onRequest hook', async () => { + const client = createClient({ + onRequest: config => + Promise.resolve({ + ...config, + headers: { ...config.headers, Authorization: 'Bearer async-token' } + }) + }); + + const response = await client.get('/echo-headers').asJson<{ authorization: string | null }>().execute(); + + expect(response.data?.authorization).toBe('Bearer async-token'); + }); + + test('passes an invalid config returned by onRequest to onRequestError', async () => { + let caught: unknown; + const client = createClient({ + onRequest: config => ({ ...config, method: 'INVALID' } as unknown as IHttpRequestConfig), + onRequestError: error => { + caught = error; + + return { method: HTTP_METHODS.GET, url: '/items', baseUrl: BASE_URL }; + } + }); + + const response = await client.get('/items').asJson<{ id: number }>().execute(); + + expect(response.data?.id).toBe(1); + expect(caught).toMatchObject({ + code: REQUEST_BUILDER_ERROR_CODES.INVALID_METHOD + }); + }); + + test('rejects data: undefined returned by onRequest', async () => { + let caught: unknown; + const client = createClient({ + onRequest: config => ({ ...config, data: undefined }), + onRequestError: error => { + caught = error; + } + }); + + await expect(client.post('/items').body({ valid: true }).execute()).rejects.toMatchObject({ + code: REQUEST_BUILDER_ERROR_CODES.INVALID_BODY + }); + expect(caught).toMatchObject({ code: REQUEST_BUILDER_ERROR_CODES.INVALID_BODY }); + }); + + test('rejects an invalid validateStatus returned by onRequest', async () => { + const client = createClient({ + onRequest: config => ({ ...config, validateStatus: true } as unknown as IHttpRequestConfig) + }); + + await expect(client.get('/items').execute()).rejects.toMatchObject({ + code: REQUEST_BUILDER_ERROR_CODES.INVALID_VALIDATE_STATUS + }); + }); + + test('rejects an invalid config returned by onRequestError without sending the request', async () => { + let hits = 0; + server.use( + http.get(`${BASE_URL}/items`, () => { + hits += 1; + + return HttpResponse.json({}); + }) + ); + const onResponseError = vi.fn(); + const client = createClient({ + onRequest: () => { + throw new Error('token storage failed'); + }, + onRequestError: () => ({ method: 'INVALID', url: '/items' } as unknown as IHttpRequestConfig), + onResponseError + }); + + await expect(client.get('/items').execute()).rejects.toMatchObject({ + code: REQUEST_BUILDER_ERROR_CODES.INVALID_METHOD + }); + expect(hits).toBe(0); + expect(onResponseError).not.toHaveBeenCalled(); + }); + + test('does not call the adapter and propagates the error when onRequest throws', async () => { + let hits = 0; + server.use( + http.get(`${BASE_URL}/items`, () => { + hits += 1; + + return HttpResponse.json({}); + }) + ); + + const hookError = new Error('token storage failed'); + const onResponseError = vi.fn(); + const client = createClient({ + onRequest: () => { + throw hookError; + }, + onResponseError + }); + + await expect(client.get('/items').execute()).rejects.toBe(hookError); + expect(hits).toBe(0); + expect(onResponseError).not.toHaveBeenCalled(); + }); + + test('resumes the request when onRequestError returns a config', async () => { + const client = createClient({ + onRequest: () => { + throw new Error('no token'); + }, + onRequestError: error => { + expect(error).toBeInstanceOf(Error); + + return { method: HTTP_METHODS.GET, url: '/items', baseUrl: BASE_URL }; + } + }); + + const response = await client.get('/items').asJson<{ id: number }>().execute(); + + expect(response.data?.id).toBe(1); + }); + + test('propagates the original error when onRequestError returns nothing', async () => { + const hookError = new Error('no token'); + const client = createClient({ + onRequest: () => { + throw hookError; + }, + onRequestError: () => undefined + }); + + await expect(client.get('/items').execute()).rejects.toBe(hookError); + }); + + test('onResponse can transform the response and onResponseError is not called on success', async () => { + const onResponse = vi.fn((response: IHttpResponse) => ({ + ...response, + data: { wrapped: response.data } + })); + const onResponseError = vi.fn(); + const client = createClient({ onResponse, onResponseError }); + + const response = await client.get('/items').asJson<{ wrapped: { id: number; name: string } }>().execute(); + + expect(onResponse).toHaveBeenCalledTimes(1); + expect(response.data?.wrapped).toEqual({ id: 1, name: 'John' }); + expect(onResponseError).not.toHaveBeenCalled(); + }); + + test('routes an onResponse failure to onResponseError', async () => { + const transformError = new Error('bad envelope'); + const onResponseError = vi.fn(() => undefined); + const client = createClient({ + onResponse: () => { + throw transformError; + }, + onResponseError + }); + + await expect(client.get('/items').execute()).rejects.toBe(transformError); + expect(onResponseError).toHaveBeenCalledTimes(1); + expect(onResponseError).toHaveBeenCalledWith(transformError); + }); + + test('onResponseError receives HttpResponseError with parsed data on 404, onResponse is not called', async () => { + const onResponse = vi.fn((response: IHttpResponse) => response); + let caught: unknown; + const client = createClient({ + onResponse, + onResponseError: error => { + caught = error; + + return undefined; + } + }); + + await expect(client.get('/not-found').execute()).rejects.toBeInstanceOf(HttpResponseError); + expect(onResponse).not.toHaveBeenCalled(); + expect(caught).toBeInstanceOf(HttpResponseError); + expect((caught as HttpResponseError).status).toBe(HTTP_STATUS_CODES.NOT_FOUND); + expect((caught as HttpResponseError).data).toEqual({ error: 'Not found' }); + }); + + test('onResponseError receives NetworkError on a network failure', async () => { + let caught: unknown; + const client = createClient({ + onResponseError: error => { + caught = error; + + return undefined; + } + }); + + await expect(client.get('/network-error').execute()).rejects.toBeInstanceOf(NetworkError); + expect(caught).toBeInstanceOf(NetworkError); + }); + + test('onResponseError receives RequestPreparationError when request serialization fails', async () => { + const body: { self?: unknown } = {}; + body.self = body; + let caught: unknown; + const client = createClient({ + onResponseError: error => { + caught = error; + + return undefined; + } + }); + + await expect(client.post('/items').body(body).execute()).rejects.toBeInstanceOf(RequestPreparationError); + expect(caught).toBeInstanceOf(RequestPreparationError); + expect(caught).toMatchObject({ config: { method: HTTP_METHODS.POST, url: '/items', baseUrl: BASE_URL } }); + expect((caught as RequestPreparationError).cause).toBeInstanceOf(TypeError); + }); + + test('onResponseError receives AbortError for an already aborted signal', async () => { + let caught: unknown; + const client = createClient({ + onResponseError: error => { + caught = error; + + return undefined; + } + }); + + const controller = new AbortController(); + controller.abort(); + + await expect(client.get('/items').signal(controller.signal).execute()).rejects.toBeInstanceOf(AbortError); + expect(caught).toBeInstanceOf(AbortError); + }); + + test('returns a recovered response from onResponseError without re-running onResponse', async () => { + const onResponse = vi.fn((response: IHttpResponse) => response); + const client = createClient({ + onResponse, + onResponseError: () => ({ + data: { recovered: true }, + status: HTTP_STATUS_CODES.OK, + statusText: 'OK', + headers: {}, + config: { method: HTTP_METHODS.GET, url: '/not-found' } + }) + }); + + const response = await client.get('/not-found').asJson<{ recovered: boolean }>().execute(); + + expect(response.data).toEqual({ recovered: true }); + expect(onResponse).not.toHaveBeenCalled(); + }); + + test('propagates the error thrown by onResponseError', async () => { + const domainError = new Error('domain error'); + const client = createClient({ + onResponseError: () => { + throw domainError; + } + }); + + await expect(client.get('/not-found').execute()).rejects.toBe(domainError); + }); + + test('does not invoke any hook when request building fails', () => { + const onRequest = vi.fn((config: IHttpRequestConfig) => config); + const onRequestError = vi.fn(); + const onResponse = vi.fn((response: IHttpResponse) => response); + const onResponseError = vi.fn(); + const client = createClient({ onRequest, onRequestError, onResponse, onResponseError }); + + expect(() => client.get('/items').body({ value: true })).toThrowError(RequestBuilderError); + expect(onRequest).not.toHaveBeenCalled(); + expect(onRequestError).not.toHaveBeenCalled(); + expect(onResponse).not.toHaveBeenCalled(); + expect(onResponseError).not.toHaveBeenCalled(); + }); + + test('applies hooks set on the instance', async () => { + const client = createClient().onRequest(config => ({ + ...config, + headers: { ...config.headers, Authorization: 'Bearer instance-token' } + })); + + const response = await client.get('/echo-headers').asJson<{ authorization: string | null }>().execute(); + + expect(response.data?.authorization).toBe('Bearer instance-token'); + }); + + test('instance hooks override constructor options hooks', async () => { + const client = createClient({ + onRequest: config => ({ + ...config, + headers: { ...config.headers, Authorization: 'Bearer constructor-token' } + }) + }); + + client.onRequest(config => ({ + ...config, + headers: { ...config.headers, Authorization: 'Bearer override-token' } + })); + + const response = await client.get('/echo-headers').asJson<{ authorization: string | null }>().execute(); + + expect(response.data?.authorization).toBe('Bearer override-token'); + }); + + test('supports chaining when setting hooks on the instance', async () => { + const client = createClient(); + + const chained = client + .onRequest(config => config) + .onRequestError(() => undefined) + .onResponse(response => ({ ...response, data: { chained: true } })) + .onResponseError(() => undefined); + + expect(chained).toBe(client); + + const response = await client.get('/items').asJson<{ chained: boolean }>().execute(); + + expect(response.data).toEqual({ chained: true }); + }); + + test('applies instance hooks to subsequent requests only', async () => { + const client = createClient(); + + const before = await client.get('/echo-headers').asJson<{ authorization: string | null }>().execute(); + + expect(before.data?.authorization).toBeNull(); + + client.onRequest(config => ({ + ...config, + headers: { ...config.headers, Authorization: 'Bearer late-token' } + })); + + const after = await client.get('/echo-headers').asJson<{ authorization: string | null }>().execute(); + + expect(after.data?.authorization).toBe('Bearer late-token'); + }); + + test('applies an instance onResponseError hook', async () => { + const client = createClient(); + let caught: unknown; + + client.onResponseError(error => { + caught = error; + + return undefined; + }); + + await expect(client.get('/not-found').execute()).rejects.toBeInstanceOf(HttpResponseError); + expect(caught).toBeInstanceOf(HttpResponseError); + expect((caught as HttpResponseError).status).toBe(HTTP_STATUS_CODES.NOT_FOUND); + }); +}); + +describe('XhrAdapter preparation errors', () => { + test('wraps a synchronous xhr.open failure', async () => { + const cause = new DOMException('Invalid URL', 'SyntaxError'); + const xhr = { + open: vi.fn(() => { + throw cause; + }) + } as unknown as XMLHttpRequest; + vi.stubGlobal( + 'XMLHttpRequest', + vi.fn(() => xhr) + ); + + const client = new HttpClient({ adapter: new XhrAdapter(), baseUrl: BASE_URL }); + + try { + await client.get('/items').execute(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(RequestPreparationError); + expect((error as RequestPreparationError).cause).toBe(cause); + expect((error as RequestPreparationError).config).toMatchObject({ url: '/items', baseUrl: BASE_URL }); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/services/http-client/test/core/HttpClient.withAdapter.tests.ts b/services/http-client/test/core/HttpClient.withAdapter.tests.ts new file mode 100644 index 00000000..4186a18f --- /dev/null +++ b/services/http-client/test/core/HttpClient.withAdapter.tests.ts @@ -0,0 +1,113 @@ +import { HTTP_STATUS_CODES, REQUEST_BUILDER_ERROR_CODES } from '../../src/constants'; +import { HttpClient } from '../../src/core/HttpClient'; +import { RequestBuilderError } from '../../src/errors'; +import { IHttpClientAdapter, IHttpRequestConfig, IHttpResponse, TRequestBuilderErrorCode } from '../../src/types'; + +function createCapturingAdapter(): { adapter: IHttpClientAdapter; configs: IHttpRequestConfig[] } { + const configs: IHttpRequestConfig[] = []; + const adapter: IHttpClientAdapter = { + request(config: IHttpRequestConfig): Promise> { + configs.push(config); + + return Promise.resolve({ + status: HTTP_STATUS_CODES.OK, + statusText: 'OK', + headers: {}, + config + }); + } + }; + + return { adapter, configs }; +} + +function expectRequestBuilderError(action: () => unknown, code: TRequestBuilderErrorCode): void { + try { + action(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(RequestBuilderError); + expect(error).toMatchObject({ code }); + } +} + +describe('HttpClient.withAdapter', () => { + test('creates an independent client with the provided adapter and inherited defaults', async () => { + const original = createCapturingAdapter(); + const replacement = createCapturingAdapter(); + const validateStatus = (status: number): boolean => status < 500; + const client = new HttpClient({ + adapter: original.adapter, + baseUrl: 'https://api.example.test', + headers: { 'X-Default': 'default' }, + params: { locale: 'ru' }, + timeout: 1000, + validateStatus, + withCredentials: true + }); + + const scopedClient = client.withAdapter(replacement.adapter); + + expect(scopedClient).not.toBe(client); + + await scopedClient.get('/scoped').execute(); + await client.get('/original').execute(); + + expect(replacement.configs).toEqual([ + expect.objectContaining({ + url: '/scoped', + baseUrl: 'https://api.example.test', + headers: { 'X-Default': 'default' }, + params: { locale: 'ru' }, + timeout: 1000, + validateStatus, + withCredentials: true + }) + ]); + expect(original.configs).toEqual([expect.objectContaining({ url: '/original' })]); + }); + + test('snapshots current hooks and allows clients to replace them independently', async () => { + const original = createCapturingAdapter(); + const replacement = createCapturingAdapter(); + const client = new HttpClient({ adapter: original.adapter }).onRequest(config => ({ + ...config, + headers: { ...config.headers, 'X-Hook': 'snapshot' } + })); + const scopedClient = client.withAdapter(replacement.adapter); + + client.onRequest(config => ({ + ...config, + headers: { ...config.headers, 'X-Hook': 'original' } + })); + + await scopedClient.get('/scoped').execute(); + await client.get('/original').execute(); + + expect(replacement.configs[0]?.headers).toMatchObject({ 'X-Hook': 'snapshot' }); + expect(original.configs[0]?.headers).toMatchObject({ 'X-Hook': 'original' }); + + scopedClient.onRequest(config => ({ + ...config, + headers: { ...config.headers, 'X-Hook': 'scoped' } + })); + + await scopedClient.get('/scoped-again').execute(); + await client.get('/original-again').execute(); + + expect(replacement.configs[1]?.headers).toMatchObject({ 'X-Hook': 'scoped' }); + expect(original.configs[1]?.headers).toMatchObject({ 'X-Hook': 'original' }); + }); + + test.each([ + { name: 'undefined', adapter: undefined }, + { name: 'an invalid object', adapter: {} } + ])('rejects $name instead of falling back to FetchAdapter', ({ adapter }) => { + const client = new HttpClient({}); + + expectRequestBuilderError( + () => client.withAdapter(adapter as unknown as IHttpClientAdapter), + REQUEST_BUILDER_ERROR_CODES.INVALID_ADAPTER + ); + }); +}); diff --git a/services/http-client/test/core/HttpRequestBuilder.tests-d.ts b/services/http-client/test/core/HttpRequestBuilder.tests-d.ts new file mode 100644 index 00000000..fccb399c --- /dev/null +++ b/services/http-client/test/core/HttpRequestBuilder.tests-d.ts @@ -0,0 +1,79 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { HTTP_METHODS, HTTP_STATUS_CODES } from '../../src/constants'; +import { HttpRequestBuilder } from '../../src/core/HttpRequestBuilder'; +import { IHttpRequestConfig, IHttpResponse, THttpParams, THttpRequestExecutor, TValidateStatus } from '../../src/types'; + +const executor: THttpRequestExecutor = (config: IHttpRequestConfig): Promise> => + Promise.resolve({ + status: HTTP_STATUS_CODES.OK, + statusText: 'OK', + headers: {}, + config + }); + +describe('HttpRequestBuilder types', () => { + it('selects the response data type before execution', () => { + const getBuilder = new HttpRequestBuilder(executor, HTTP_METHODS.GET, '/items'); + const postBuilder = new HttpRequestBuilder(executor, HTTP_METHODS.POST, '/items'); + + expectTypeOf(getBuilder).toEqualTypeOf(); + expectTypeOf(postBuilder).toEqualTypeOf(); + expectTypeOf(postBuilder.build()).toEqualTypeOf>(); + expectTypeOf(postBuilder.execute()).toEqualTypeOf>>(); + expectTypeOf(postBuilder.asJson<{ id: number }>().execute()).toEqualTypeOf< + Promise> + >(); + expectTypeOf(postBuilder.asText().execute()).toEqualTypeOf>>(); + expectTypeOf(postBuilder.asBlob().execute()).toEqualTypeOf>>(); + expectTypeOf(postBuilder.asArrayBuffer().execute()).toEqualTypeOf>>(); + expectTypeOf(postBuilder.asStream().execute()).toEqualTypeOf< + Promise>> + >(); + + if (false) { + // @ts-expect-error The response type must be selected before execute. + postBuilder.execute<{ id: number }>(); + } + }); + + it('preserves and replaces the selected response type across the immutable chain', () => { + const builder = new HttpRequestBuilder(executor, HTTP_METHODS.POST, '/items') + .asJson<{ id: number }>() + .header('X-Test', 'value') + .body({ name: 'Item' }) + .timeout(1000); + + expectTypeOf(builder.execute()).toEqualTypeOf>>(); + expectTypeOf(builder.validateStatus(status => status === 201).execute()).toEqualTypeOf< + Promise> + >(); + expectTypeOf(builder.asBlob().execute()).toEqualTypeOf>>(); + }); + + it('allows body calls for every method at compile time', () => { + const getBuilder = new HttpRequestBuilder(executor, HTTP_METHODS.GET, '/items'); + const headBuilder = new HttpRequestBuilder(executor, HTTP_METHODS.HEAD, '/items'); + const postBuilder = new HttpRequestBuilder(executor, HTTP_METHODS.POST, '/items'); + + expectTypeOf(getBuilder.body({ value: true })).toEqualTypeOf(); + expectTypeOf(headBuilder.body({ value: true })).toEqualTypeOf(); + expectTypeOf(postBuilder.body({ value: true })).toEqualTypeOf(); + expectTypeOf(postBuilder.withCredentials(true)).toEqualTypeOf(); + expectTypeOf(postBuilder.validateStatus(status => status === 200)).toEqualTypeOf(); + }); + + it('exports the validateStatus predicate type', () => { + const validateStatus: TValidateStatus = status => status >= 200 && status < 400; + + expectTypeOf(validateStatus).toEqualTypeOf(); + }); + + it('allows primitive query params', () => { + const params: THttpParams = { page: 2, active: true, role: ['admin', false] }; + const builder = new HttpRequestBuilder(executor, HTTP_METHODS.GET, '/items'); + + expectTypeOf(params).toEqualTypeOf(); + expectTypeOf(builder.param('page', 2)).toEqualTypeOf(); + expectTypeOf(builder.param('active', true)).toEqualTypeOf(); + }); +}); diff --git a/services/http-client/test/core/HttpRequestBuilder.tests.ts b/services/http-client/test/core/HttpRequestBuilder.tests.ts new file mode 100644 index 00000000..b00b70fe --- /dev/null +++ b/services/http-client/test/core/HttpRequestBuilder.tests.ts @@ -0,0 +1,283 @@ +import { HTTP_METHODS, HTTP_RESPONSE_TYPES, HTTP_STATUS_CODES, REQUEST_BUILDER_ERROR_CODES } from '../../src/constants'; +import { HttpRequestBuilder } from '../../src/core/HttpRequestBuilder'; +import { RequestBuilderError } from '../../src/errors'; +import { + IHttpRequestConfig, + IHttpResponse, + THttpHeaders, + THttpMethod, + THttpParams, + THttpRequestExecutor, + TRequestBuilderErrorCode +} from '../../src/types'; + +function createExecutor(configs: IHttpRequestConfig[] = []): THttpRequestExecutor { + return (config: IHttpRequestConfig): Promise> => { + configs.push(config); + + return Promise.resolve({ + status: HTTP_STATUS_CODES.OK, + statusText: 'OK', + headers: {}, + config + }); + }; +} + +function expectRequestBuilderError(action: () => unknown, code: TRequestBuilderErrorCode): void { + try { + action(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(RequestBuilderError); + expect(error).toMatchObject({ code }); + } +} + +describe('HttpRequestBuilder', () => { + test('builds a request config through an immutable chain', () => { + const executor = createExecutor(); + const controller = new AbortController(); + const validateStatus = (status: number): boolean => status < 500; + const initialBuilder = new HttpRequestBuilder(executor, HTTP_METHODS.POST, '/items'); + const configuredBuilder = initialBuilder + .baseUrl('https://example.test') + .header('X-First', 'first') + .headers({ 'X-Second': 'second' }) + .param('page', '2') + .params({ role: ['admin', 'user'] }) + .body({ name: 'Item' }) + .signal(controller.signal) + .timeout(0) + .validateStatus(validateStatus) + .withCredentials(true) + .bearer('token') + .asJson(); + + expect(initialBuilder.build()).toEqual({ method: HTTP_METHODS.POST, url: '/items' }); + expect(configuredBuilder).not.toBe(initialBuilder); + expect(configuredBuilder.build()).toEqual({ + method: HTTP_METHODS.POST, + url: '/items', + baseUrl: 'https://example.test', + headers: { + 'X-First': 'first', + 'X-Second': 'second', + Authorization: 'Bearer token' + }, + params: { page: '2', role: ['admin', 'user'] }, + data: { name: 'Item' }, + signal: controller.signal, + timeout: 0, + validateStatus, + withCredentials: true, + responseType: HTTP_RESPONSE_TYPES.JSON + }); + }); + + test('creates independent branches and build snapshots', () => { + const sourceHeaders: THttpHeaders = { 'X-Source': 'source' }; + const sourceParams: THttpParams = { role: ['admin'] }; + const builder = new HttpRequestBuilder(createExecutor(), HTTP_METHODS.GET, '/items') + .headers(sourceHeaders) + .params(sourceParams); + const firstBranch = builder.header('X-Branch', 'first').param('page', '1'); + const secondBranch = builder.header('X-Branch', 'second').param('page', '2'); + + sourceHeaders['X-Source'] = 'changed'; + (sourceParams.role as string[]).push('user'); + + const firstSnapshot = firstBranch.build(); + if (!firstSnapshot.headers || !firstSnapshot.params || !Array.isArray(firstSnapshot.params.role)) { + throw new Error('Expected headers and array params in the snapshot'); + } + firstSnapshot.headers['X-Source'] = 'mutated snapshot'; + firstSnapshot.params.role.push('editor'); + + expect(builder.build()).toMatchObject({ + headers: { 'X-Source': 'source' }, + params: { role: ['admin'] } + }); + expect(firstBranch.build()).toMatchObject({ + headers: { 'X-Source': 'source', 'X-Branch': 'first' }, + params: { role: ['admin'], page: '1' } + }); + expect(secondBranch.build()).toMatchObject({ + headers: { 'X-Source': 'source', 'X-Branch': 'second' }, + params: { role: ['admin'], page: '2' } + }); + }); + + test('executes a built snapshot through the generic executor', async () => { + const configs: IHttpRequestConfig[] = []; + const builder = new HttpRequestBuilder(createExecutor(configs), HTTP_METHODS.GET, '/items').param('page', '1'); + + const response = await builder.asJson<{ id: number }>().execute(); + + expect(response.status).toBe(HTTP_STATUS_CODES.OK); + expect(configs).toEqual([ + { + method: HTTP_METHODS.GET, + url: '/items', + params: { page: '1' }, + responseType: HTTP_RESPONSE_TYPES.JSON + } + ]); + expect(response.config).toBe(configs[0]); + }); + + test.each([ + { + name: 'executor', + action: () => + new HttpRequestBuilder(undefined as unknown as THttpRequestExecutor, HTTP_METHODS.GET, '/items'), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_EXECUTOR + }, + { + name: 'method', + action: () => new HttpRequestBuilder(createExecutor(), 'INVALID' as THttpMethod, '/items'), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_METHOD + }, + { + name: 'CONNECT method', + action: () => new HttpRequestBuilder(createExecutor(), 'CONNECT' as THttpMethod, '/items'), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_METHOD + }, + { + name: 'TRACE method', + action: () => new HttpRequestBuilder(createExecutor(), 'TRACE' as THttpMethod, '/items'), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_METHOD + }, + { + name: 'URL', + action: () => new HttpRequestBuilder(createExecutor(), HTTP_METHODS.GET, ' '), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_URL + } + ])('validates $name in the constructor', ({ action, code }) => { + expectRequestBuilderError(action, code); + }); + + test.each([ + { + name: 'base URL', + action: (builder: HttpRequestBuilder) => builder.baseUrl(' '), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_BASE_URL + }, + { + name: 'header key', + action: (builder: HttpRequestBuilder) => builder.header('Bad Header', 'value'), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_HEADER + }, + { + name: 'header value', + action: (builder: HttpRequestBuilder) => builder.header('X-Test', 'value\nInjected'), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_HEADER + }, + { + name: 'headers object', + action: (builder: HttpRequestBuilder) => builder.headers([] as unknown as THttpHeaders), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_HEADERS + }, + { + name: 'headers values', + action: (builder: HttpRequestBuilder) => builder.headers({ 'X-Test': 1 } as unknown as THttpHeaders), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_HEADER + }, + { + name: 'param key', + action: (builder: HttpRequestBuilder) => builder.param(' ', 'value'), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_PARAM + }, + { + name: 'param value', + action: (builder: HttpRequestBuilder) => builder.param('page', Number.NaN), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_PARAM + }, + { + name: 'param object value', + action: (builder: HttpRequestBuilder) => builder.param('page', {} as never), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_PARAM + }, + { + name: 'params object', + action: (builder: HttpRequestBuilder) => builder.params([] as unknown as THttpParams), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_PARAMS + }, + { + name: 'signal', + action: (builder: HttpRequestBuilder) => builder.signal({} as AbortSignal), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_SIGNAL + }, + { + name: 'timeout', + action: (builder: HttpRequestBuilder) => builder.timeout(Number.POSITIVE_INFINITY), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_TIMEOUT + }, + { + name: 'with credentials', + action: (builder: HttpRequestBuilder) => builder.withCredentials('true' as unknown as boolean), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_WITH_CREDENTIALS + }, + { + name: 'validate status', + action: (builder: HttpRequestBuilder) => + builder.validateStatus(true as unknown as (status: number) => boolean), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_VALIDATE_STATUS + }, + { + name: 'bearer token', + action: (builder: HttpRequestBuilder) => builder.bearer(' '), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_BEARER_TOKEN + } + ])('validates $name', ({ action, code }) => { + const builder = new HttpRequestBuilder(createExecutor(), HTTP_METHODS.POST, '/items'); + + expectRequestBuilderError(() => action(builder), code); + }); + + test.each([HTTP_METHODS.GET, HTTP_METHODS.HEAD])('rejects a body for %s at runtime', method => { + const builder: HttpRequestBuilder = new HttpRequestBuilder(createExecutor(), method, '/items'); + + expectRequestBuilderError(() => builder.body({ value: true }), REQUEST_BUILDER_ERROR_CODES.INVALID_BODY); + }); + + test('allows a body for DELETE', () => { + const builder = new HttpRequestBuilder(createExecutor(), HTTP_METHODS.DELETE, '/items/1'); + + expect(builder.body({ hard: true }).build().data).toEqual({ hard: true }); + }); + + test('rejects an explicitly undefined body', () => { + const builder = new HttpRequestBuilder(createExecutor(), HTTP_METHODS.POST, '/items'); + + expectRequestBuilderError(() => builder.body(undefined), REQUEST_BUILDER_ERROR_CODES.INVALID_BODY); + }); + + test('allows null as an explicit JSON body', () => { + const builder = new HttpRequestBuilder(createExecutor(), HTTP_METHODS.POST, '/items'); + + expect(builder.body(null).build().data).toBeNull(); + }); + + test('accepts nullish params and drops them on merge override', () => { + const builder = new HttpRequestBuilder(createExecutor(), HTTP_METHODS.GET, '/items') + .params({ page: 1, locale: 'ru', role: ['admin', null, 'user'] }) + .param('locale', null) + .params({ role: undefined }); + + expect(builder.build().params).toEqual({ page: 1 }); + }); + + test('sets params to undefined when no values remain after merge', () => { + const builder = new HttpRequestBuilder(createExecutor(), HTTP_METHODS.GET, '/items') + .params({ page: 1, role: [null, undefined] }) + .param('page', null); + const config = builder.build(); + + expect(config.params).toBeUndefined(); + }); + + test('keeps the default error code for backwards-compatible construction', () => { + expect(new RequestBuilderError('Invalid config').code).toBe(REQUEST_BUILDER_ERROR_CODES.INVALID_CONFIG); + }); +}); diff --git a/services/http-client/test/errors/HttpClientError.tests.ts b/services/http-client/test/errors/HttpClientError.tests.ts new file mode 100644 index 00000000..93927689 --- /dev/null +++ b/services/http-client/test/errors/HttpClientError.tests.ts @@ -0,0 +1,141 @@ +import { HTTP_METHODS, HTTP_STATUS_CODES, REQUEST_BUILDER_ERROR_CODES } from '../../src/constants'; +import { + AbortError, + HttpClientError, + HttpResponseError, + isAbortError, + isHttpClientError, + isHttpResponseError, + isNetworkError, + isParseError, + isRequestBuilderError, + isRequestPreparationError, + isTimeoutError, + NetworkError, + ParseError, + RequestBuilderError, + RequestPreparationError, + TimeoutError +} from '../../src/errors'; +import { IHttpRequestConfig } from '../../src/types'; + +const config: IHttpRequestConfig = { method: HTTP_METHODS.GET, url: '/items' }; + +const errors: Array<{ name: string; create: () => HttpClientError }> = [ + { name: 'AbortError', create: () => new AbortError('aborted') }, + { + name: 'HttpResponseError', + create: () => + new HttpResponseError('failed', HTTP_STATUS_CODES.BAD_REQUEST, 'Bad Request', {}, config, { error: 'bad' }) + }, + { name: 'NetworkError', create: () => new NetworkError('network down') }, + { name: 'ParseError', create: () => new ParseError('invalid json') }, + { + name: 'RequestBuilderError', + create: () => new RequestBuilderError('invalid', REQUEST_BUILDER_ERROR_CODES.INVALID_URL) + }, + { name: 'RequestPreparationError', create: () => new RequestPreparationError('failed', { config }) }, + { name: 'TimeoutError', create: () => new TimeoutError('timed out') } +]; + +const errorGuards: Array<{ name: string; guard: (error: unknown) => boolean; create: () => HttpClientError }> = [ + { name: 'AbortError', guard: isAbortError, create: () => new AbortError('aborted') }, + { + name: 'HttpResponseError', + guard: isHttpResponseError, + create: () => + new HttpResponseError('failed', HTTP_STATUS_CODES.BAD_REQUEST, 'Bad Request', {}, config, { error: 'bad' }) + }, + { name: 'NetworkError', guard: isNetworkError, create: () => new NetworkError('network down') }, + { name: 'ParseError', guard: isParseError, create: () => new ParseError('invalid json') }, + { + name: 'RequestBuilderError', + guard: isRequestBuilderError, + create: () => new RequestBuilderError('invalid', REQUEST_BUILDER_ERROR_CODES.INVALID_URL) + }, + { + name: 'RequestPreparationError', + guard: isRequestPreparationError, + create: () => new RequestPreparationError('failed', { config }) + }, + { name: 'TimeoutError', guard: isTimeoutError, create: () => new TimeoutError('timed out') } +]; + +describe('HttpClientError', () => { + it.each(errors)('$name is catchable via instanceof HttpClientError and Error', ({ name, create }) => { + const error = create(); + + expect(error).toBeInstanceOf(HttpClientError); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe(name); + }); + + it.each(errors)('$name keeps its own prototype for specific instanceof checks', ({ create }) => { + const error = create(); + + expect(error).toBeInstanceOf(error.constructor as new (...args: never[]) => unknown); + }); + + it.each(errorGuards)('$name guard identifies its error type', ({ guard, create }) => { + const error = create(); + + expect(isHttpClientError(error)).toBe(true); + expect(guard(error)).toBe(true); + }); + + it.each(errorGuards)('$name guard rejects unrelated errors', ({ guard }) => { + expect(guard(new Error('unrelated'))).toBe(false); + }); + + it('preserves HttpResponseError fields and base config', () => { + const headers = { 'x-a': 'b' }; + const error = new HttpResponseError('failed', HTTP_STATUS_CODES.NOT_FOUND, 'Not Found', headers, config, { + error: 'Not found' + }); + + expect(error.message).toBe('failed'); + expect(error.status).toBe(HTTP_STATUS_CODES.NOT_FOUND); + expect(error.statusText).toBe('Not Found'); + expect(error.headers).toBe(headers); + expect(error.config).toBe(config); + expect(error.data).toEqual({ error: 'Not found' }); + expect((error as HttpClientError).config).toBe(config); + }); + + it('preserves RequestBuilderError code including the default', () => { + expect(new RequestBuilderError('invalid', REQUEST_BUILDER_ERROR_CODES.INVALID_URL).code).toBe( + REQUEST_BUILDER_ERROR_CODES.INVALID_URL + ); + expect(new RequestBuilderError('invalid').code).toBe(REQUEST_BUILDER_ERROR_CODES.INVALID_CONFIG); + }); + + it('preserves ParseError fields', () => { + const cause = new SyntaxError('Unexpected token'); + const error = new ParseError('Failed to parse response body as JSON', { + cause, + config, + responseType: 'json', + raw: '{ invalid' + }); + + expect(error.cause).toBe(cause); + expect(error.config).toBe(config); + expect(error.responseType).toBe('json'); + expect(error.raw).toBe('{ invalid'); + }); + + it('preserves TimeoutError timeout', () => { + const error = new TimeoutError('Request timed out after 50ms', { config, timeout: 50 }); + + expect(error.timeout).toBe(50); + expect(error.config).toBe(config); + }); + + it('preserves the cause and config of a RequestPreparationError', () => { + const cause = new TypeError('Cannot serialize request body'); + const error = new RequestPreparationError('Failed to prepare HTTP request', { cause, config }); + + expect(error.cause).toBe(cause); + expect(error.config).toBe(config); + }); +}); diff --git a/services/http-client/test/errors/errorGuards.tests-d.ts b/services/http-client/test/errors/errorGuards.tests-d.ts new file mode 100644 index 00000000..3e5ac34b --- /dev/null +++ b/services/http-client/test/errors/errorGuards.tests-d.ts @@ -0,0 +1,76 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { + AbortError, + HttpClientError, + HttpResponseError, + isAbortError, + isHttpClientError, + isHttpResponseError, + isNetworkError, + isParseError, + isRequestBuilderError, + isRequestPreparationError, + isTimeoutError, + NetworkError, + ParseError, + RequestBuilderError, + RequestPreparationError, + TimeoutError +} from '../../src/errors'; +import { THttpResponseType } from '../../src/types'; + +interface IValidationError { + message: string; + errors: Record; +} + +function isValidationError(data: unknown): data is IValidationError { + return typeof data === 'object' && data !== null; +} + +describe('error guards', () => { + it('narrow unknown errors to their concrete types', () => { + const error: unknown = new Error('unknown'); + + if (isHttpClientError(error)) { + expectTypeOf(error).toEqualTypeOf(); + } + + if (isHttpResponseError(error)) { + expectTypeOf(error).toEqualTypeOf(); + expectTypeOf(error.data).toEqualTypeOf(); + } + + if (isHttpResponseError(error) && isValidationError(error.data)) { + expectTypeOf(error).toEqualTypeOf(); + expectTypeOf(error.data).toEqualTypeOf(); + } + + if (isNetworkError(error)) { + expectTypeOf(error).toEqualTypeOf(); + } + + if (isTimeoutError(error)) { + expectTypeOf(error).toEqualTypeOf(); + expectTypeOf(error.timeout).toEqualTypeOf(); + } + + if (isAbortError(error)) { + expectTypeOf(error).toEqualTypeOf(); + } + + if (isParseError(error)) { + expectTypeOf(error).toEqualTypeOf(); + expectTypeOf(error.responseType).toEqualTypeOf(); + expectTypeOf(error.raw).toEqualTypeOf(); + } + + if (isRequestBuilderError(error)) { + expectTypeOf(error).toEqualTypeOf(); + } + + if (isRequestPreparationError(error)) { + expectTypeOf(error).toEqualTypeOf(); + } + }); +}); diff --git a/services/http-client/test/types/AdapterOptions.tests-d.ts b/services/http-client/test/types/AdapterOptions.tests-d.ts new file mode 100644 index 00000000..2b4e0a01 --- /dev/null +++ b/services/http-client/test/types/AdapterOptions.tests-d.ts @@ -0,0 +1,43 @@ +import { + FetchAdapter, + HttpClient, + IFetchAdapterOptions, + IHttpRequestConfig, + IXhrAdapterOptions, + XhrAdapter +} from '../../src'; + +describe('adapter option types', () => { + it('accepts supported Fetch and XHR options', () => { + const fetchOptions: IFetchAdapterOptions = { + cache: 'no-store', + credentials: 'omit', + integrity: 'sha256-test', + keepalive: true, + mode: 'cors', + redirect: 'error', + referrer: 'https://app.example.test', + referrerPolicy: 'no-referrer' + }; + const xhrOptions: IXhrAdapterOptions = { + mimeType: 'application/json', + responseType: 'text', + timeout: 1000, + withCredentials: true, + onDownloadProgress: (event, config) => { + expectTypeOf(event).toEqualTypeOf(); + expectTypeOf(config).toEqualTypeOf>(); + }, + onUploadProgress: (event, config) => { + expectTypeOf(event).toEqualTypeOf(); + expectTypeOf(config).toEqualTypeOf>(); + } + }; + const client = new HttpClient({ adapter: new FetchAdapter() }); + + expectTypeOf(new FetchAdapter(fetchOptions)).toEqualTypeOf(); + expectTypeOf(new XhrAdapter(xhrOptions)).toEqualTypeOf(); + expectTypeOf(client.withAdapter(new XhrAdapter(xhrOptions))).toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf<'cors' | 'no-cors' | 'same-origin'>(); + }); +}); diff --git a/services/http-client/test/types/HttpHooks.tests-d.ts b/services/http-client/test/types/HttpHooks.tests-d.ts new file mode 100644 index 00000000..2d7b6f09 --- /dev/null +++ b/services/http-client/test/types/HttpHooks.tests-d.ts @@ -0,0 +1,96 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { HTTP_METHODS, HTTP_STATUS_CODES } from '../../src/constants'; +import { HttpResponseError } from '../../src/errors'; +import { + IHttpRequestConfig, + IHttpResponse, + THttpRequestErrorHook, + THttpRequestHook, + THttpResponseErrorHook, + THttpResponseHook +} from '../../src/types'; + +const config: IHttpRequestConfig = { method: HTTP_METHODS.GET, url: '/items' }; +const response: IHttpResponse = { + status: HTTP_STATUS_CODES.OK, + statusText: 'OK', + headers: {}, + config +}; + +const nonStandardStatusResponse: IHttpResponse = { + status: 499, + statusText: 'Client Closed Request', + headers: {}, + config +}; + +describe('THttpRequestHook', () => { + it('requires a config to be returned, sync or async', () => { + const hooks: THttpRequestHook[] = [value => value, value => ({ ...value, headers: {} }), async value => value]; + + expectTypeOf(hooks).toEqualTypeOf(); + }); +}); + +describe('THttpResponseHook', () => { + it('requires a response to be returned, sync or async', () => { + const hooks: THttpResponseHook[] = [value => value, async value => value]; + + expectTypeOf(hooks).toEqualTypeOf(); + }); +}); + +describe('HTTP status', () => { + it('allows status codes that are not included in HTTP_STATUS_CODES', () => { + const error = new HttpResponseError('request failed', 499, 'Client Closed Request', {}, config); + + expectTypeOf(nonStandardStatusResponse.status).toEqualTypeOf(); + expectTypeOf(error.status).toEqualTypeOf(); + }); +}); + +describe('THttpRequestErrorHook', () => { + it('allows returning a config, nothing, or a promise of either', () => { + const hooks: THttpRequestErrorHook[] = [ + () => config, + () => undefined, + () => {}, + async () => config, + async () => undefined, + async () => {}, + () => (Date.now() > 0 ? config : undefined) + ]; + + expectTypeOf(hooks).toEqualTypeOf(); + }); +}); + +describe('THttpResponseErrorHook', () => { + it('accepts errors from adapters and response hooks', () => { + expectTypeOf().parameter(0).toEqualTypeOf(); + + const narrowed: THttpResponseErrorHook = error => { + if (error instanceof HttpResponseError) { + expectTypeOf(error.status).toEqualTypeOf(); + expectTypeOf(error.config).toEqualTypeOf(); + } + }; + + expectTypeOf(narrowed).toEqualTypeOf(); + }); + + it('allows returning a response, nothing, or a promise of either', () => { + const hooks: THttpResponseErrorHook[] = [ + () => response, + () => undefined, + () => {}, + async () => response, + async () => undefined, + async () => {}, + () => (Date.now() > 0 ? response : undefined) + ]; + + expectTypeOf(hooks).toEqualTypeOf(); + }); +}); diff --git a/services/http-client/test/utilities/buildUrl.tests.ts b/services/http-client/test/utilities/buildUrl.tests.ts new file mode 100644 index 00000000..2d7dd3d1 --- /dev/null +++ b/services/http-client/test/utilities/buildUrl.tests.ts @@ -0,0 +1,34 @@ +import { buildUrl } from '../../src/utilities/buildUrl'; + +describe('buildUrl', () => { + test('joins an absolute base URL with a relative request URL', () => { + expect(buildUrl('https://api.example.test/v1/', '/users')).toBe('https://api.example.test/v1/users'); + }); + + test('preserves existing query values and appends params before the fragment', () => { + expect( + buildUrl('https://api.example.test/v1?locale=ru', '/users?sort=name#list', { + page: 2, + active: true, + role: ['admin', 'editor'], + omitted: null + }) + ).toBe('https://api.example.test/v1/users?locale=ru&sort=name&page=2&active=true&role=admin&role=editor#list'); + }); + + test('does not apply baseUrl to an absolute request URL', () => { + expect( + buildUrl('https://api.example.test/v1', 'https://cdn.example.test/file?id=1#preview', { raw: false }) + ).toBe('https://cdn.example.test/file?id=1&raw=false#preview'); + }); + + test('supports relative base and request URLs', () => { + expect(buildUrl('/api?locale=ru', '/users?sort=name#list', { page: 1 })).toBe( + '/api/users?locale=ru&sort=name&page=1#list' + ); + }); + + test('returns the request URL unchanged when baseUrl and params are absent', () => { + expect(buildUrl(undefined, '/users#list')).toBe('/users#list'); + }); +}); diff --git a/services/http-client/test/utilities/getErrorMessage.tests.ts b/services/http-client/test/utilities/getErrorMessage.tests.ts new file mode 100644 index 00000000..37426b8e --- /dev/null +++ b/services/http-client/test/utilities/getErrorMessage.tests.ts @@ -0,0 +1,11 @@ +import { getErrorMessage } from '../../src/utilities/getErrorMessage'; + +describe('getErrorMessage', () => { + test('returns a non-empty Error message', () => { + expect(getErrorMessage(new Error('Request failed'), 'Fallback')).toBe('Request failed'); + }); + + test.each([new Error(''), 'Request failed', null, undefined])('returns the fallback for %p', error => { + expect(getErrorMessage(error, 'Fallback')).toBe('Fallback'); + }); +}); diff --git a/services/http-client/test/utilities/isStatusAccepted.tests.ts b/services/http-client/test/utilities/isStatusAccepted.tests.ts new file mode 100644 index 00000000..2b64d673 --- /dev/null +++ b/services/http-client/test/utilities/isStatusAccepted.tests.ts @@ -0,0 +1,20 @@ +import { isStatusAccepted } from '../../src/utilities/isStatusAccepted'; + +describe('isStatusAccepted', () => { + test.each([ + { status: 199, expected: false }, + { status: 200, expected: true }, + { status: 299, expected: true }, + { status: 300, expected: false } + ])('returns $expected for status $status by default', ({ status, expected }) => { + expect(isStatusAccepted(status)).toBe(expected); + }); + + test('uses the provided predicate', () => { + const validateStatus = vi.fn((status: number) => status === 404); + + expect(isStatusAccepted(404, validateStatus)).toBe(true); + expect(validateStatus).toHaveBeenCalledOnce(); + expect(validateStatus).toHaveBeenCalledWith(404); + }); +}); diff --git a/services/http-client/test/utilities/mergeHeaders.tests.ts b/services/http-client/test/utilities/mergeHeaders.tests.ts new file mode 100644 index 00000000..df13e4b8 --- /dev/null +++ b/services/http-client/test/utilities/mergeHeaders.tests.ts @@ -0,0 +1,35 @@ +import { hasHeader, mergeHeaders } from '../../src/utilities/mergeHeaders'; + +describe('mergeHeaders', () => { + test('merges sources from left to right using case-insensitive names', () => { + expect( + mergeHeaders( + { Authorization: 'Bearer default', Accept: 'application/json' }, + { authorization: 'Bearer request', 'X-Request': 'request' } + ) + ).toEqual({ Accept: 'application/json', authorization: 'Bearer request', 'X-Request': 'request' }); + }); + + test('returns an independent object without mutating sources', () => { + const headers = { Accept: 'application/json' }; + const result = mergeHeaders(headers); + + result.Accept = 'text/plain'; + + expect(headers).toEqual({ Accept: 'application/json' }); + }); + + test('returns an empty object when sources are absent', () => { + expect(mergeHeaders(undefined)).toEqual({}); + }); +}); + +describe('hasHeader', () => { + test('finds a header using case-insensitive comparison', () => { + expect(hasHeader({ 'content-type': 'application/json' }, 'Content-Type')).toBe(true); + }); + + test('returns false when the header is absent', () => { + expect(hasHeader({ Accept: 'application/json' }, 'Content-Type')).toBe(false); + }); +}); diff --git a/services/http-client/test/utilities/mergeParams.tests.ts b/services/http-client/test/utilities/mergeParams.tests.ts new file mode 100644 index 00000000..2e7921f2 --- /dev/null +++ b/services/http-client/test/utilities/mergeParams.tests.ts @@ -0,0 +1,28 @@ +import { THttpParams } from '../../src/types'; +import { mergeParams } from '../../src/utilities/mergeParams'; + +describe('mergeParams', () => { + test('merges sources from left to right and removes nullish overrides', () => { + expect( + mergeParams({ page: 1, locale: 'ru', active: true }, { page: 2, locale: null, active: false, offset: 0 }) + ).toEqual({ page: 2, active: false, offset: 0 }); + }); + + test('drops nullish array items and copies arrays', () => { + const roles = ['admin', null, undefined, 'editor']; + const source: THttpParams = { roles }; + const result = mergeParams(source); + + expect(result).toEqual({ roles: ['admin', 'editor'] }); + expect(result?.roles).not.toBe(roles); + expect(source.roles).toBe(roles); + }); + + test.each([ + { name: 'sources are absent', sources: [] }, + { name: 'sources are undefined', sources: [undefined] }, + { name: 'only nullish values remain', sources: [{ page: null, roles: [undefined, null] }] } + ])('returns undefined when $name', ({ sources }) => { + expect(mergeParams(...(sources as Array))).toBeUndefined(); + }); +}); diff --git a/services/http-client/test/utilities/prepareRequestBody.tests.ts b/services/http-client/test/utilities/prepareRequestBody.tests.ts new file mode 100644 index 00000000..582daafe --- /dev/null +++ b/services/http-client/test/utilities/prepareRequestBody.tests.ts @@ -0,0 +1,58 @@ +import { THttpHeaders } from '../../src/types'; +import { prepareRequestBody } from '../../src/utilities/prepareRequestBody'; + +describe('prepareRequestBody', () => { + test('serializes objects as JSON and adds Content-Type to the provided headers', () => { + const headers: THttpHeaders = {}; + + expect(prepareRequestBody({ name: 'Item' }, headers)).toBe('{"name":"Item"}'); + expect(headers).toEqual({ 'Content-Type': 'application/json' }); + }); + + test('does not override an existing Content-Type with different casing', () => { + const headers = { 'content-type': 'application/vnd.api+json' }; + + expect(prepareRequestBody({ name: 'Item' }, headers)).toBe('{"name":"Item"}'); + expect(headers).toEqual({ 'content-type': 'application/vnd.api+json' }); + }); + + test('returns FormData unchanged without setting Content-Type', () => { + const headers: THttpHeaders = {}; + const formData = new FormData(); + formData.append('name', 'Item'); + + expect(prepareRequestBody(formData, headers)).toBe(formData); + expect(headers).toEqual({}); + }); + + test('serializes URLSearchParams and adds the form Content-Type', () => { + const headers: THttpHeaders = {}; + const params = new URLSearchParams({ name: 'Item', role: 'admin' }); + + expect(prepareRequestBody(params, headers)).toBe('name=Item&role=admin'); + expect(headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }); + }); + + test.each([ + { name: 'string', body: 'plain text' }, + { name: 'Blob', body: new Blob(['content']) }, + { name: 'ArrayBuffer', body: new ArrayBuffer(2) }, + { name: 'typed array', body: new Uint8Array([1, 2]) } + ])('returns a $name body unchanged without setting Content-Type', ({ body }) => { + const headers: THttpHeaders = {}; + + expect(prepareRequestBody(body, headers)).toBe(body); + expect(headers).toEqual({}); + }); + + test('throws for circular JSON data', () => { + const data: Record = {}; + data.self = data; + + expect(() => prepareRequestBody(data, {})).toThrow(TypeError); + }); + + test('throws when JSON serialization produces no body', () => { + expect(() => prepareRequestBody(undefined, {})).toThrow('Request body cannot be serialized as JSON'); + }); +}); diff --git a/services/http-client/tsconfig.build.json b/services/http-client/tsconfig.build.json new file mode 100644 index 00000000..b5935de6 --- /dev/null +++ b/services/http-client/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationDir": "dist/declarations", + "emitDeclarationOnly": true, + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/services/http-client/tsconfig.json b/services/http-client/tsconfig.json new file mode 100644 index 00000000..4ad8667c --- /dev/null +++ b/services/http-client/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["../../types.d.ts", "src", "test"], + "exclude": ["node_modules"] +} diff --git a/services/http-client/vitest.config.mjs b/services/http-client/vitest.config.mjs new file mode 100644 index 00000000..317f0c81 --- /dev/null +++ b/services/http-client/vitest.config.mjs @@ -0,0 +1,14 @@ +import { defineProject } from 'vitest/config'; + +export default defineProject({ + test: { + globals: true, + environment: 'jsdom', + include: ['test/**/*.{test,tests,spec}.[jt]s?(x)'], + setupFiles: ['../../setupTests.ts'], + typecheck: { + include: ['test/**/*.tests-d.ts'], + tsconfig: './tsconfig.json' + } + } +});