From b1dafb920c4561b0b5e62134ad363f1c6bd12e92 Mon Sep 17 00:00:00 2001 From: sadcitizen Date: Sun, 7 Jun 2026 22:32:44 +0500 Subject: [PATCH 01/60] chore: agents.md --- AGENTS.md | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..9e44d965 --- /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 +vitest run --root ../../ --project +# Example: vitest run --root ../../ --project @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`) \ No newline at end of file From 1335d562f9f8ecaa503d12ada9ba9d85bdcfa116 Mon Sep 17 00:00:00 2001 From: sadcitizen Date: Sun, 7 Jun 2026 22:36:58 +0500 Subject: [PATCH 02/60] feat(http): package init --- services/http-client/.npmignore | 1 + services/http-client/package.json | 30 ++++++++++++++++++++++++++++++ services/http-client/src/index.ts | 1 + services/http-client/tsconfig.json | 16 ++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 services/http-client/.npmignore create mode 100644 services/http-client/package.json create mode 100644 services/http-client/src/index.ts create mode 100644 services/http-client/tsconfig.json diff --git a/services/http-client/.npmignore b/services/http-client/.npmignore new file mode 100644 index 00000000..e8310385 --- /dev/null +++ b/services/http-client/.npmignore @@ -0,0 +1 @@ +src \ No newline at end of file diff --git a/services/http-client/package.json b/services/http-client/package.json new file mode 100644 index 00000000..7bc49f69 --- /dev/null +++ b/services/http-client/package.json @@ -0,0 +1,30 @@ +{ + "name": "@byndyusoft-ui/http-client", + "version": "0.0.1", + "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", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/Byndyusoft/ui.git" + }, + "scripts": { + "build": "tsc", + "clean": "rimraf dist && rimraf .turbo && rimraf node_modules && rimraf package-lock.json", + "lint": "eslint src --config ../../eslint.config.js" + }, + "bugs": { + "url": "https://github.com/Byndyusoft/ui/issues" + }, + "publishConfig": { + "access": "public" + } +} \ No newline at end of file diff --git a/services/http-client/src/index.ts b/services/http-client/src/index.ts new file mode 100644 index 00000000..56066655 --- /dev/null +++ b/services/http-client/src/index.ts @@ -0,0 +1 @@ +console.log('Привет, мир!'); \ No newline at end of file diff --git a/services/http-client/tsconfig.json b/services/http-client/tsconfig.json new file mode 100644 index 00000000..6c2dec9a --- /dev/null +++ b/services/http-client/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationDir": "dist", + "outDir": "dist", + "module": "commonjs" + }, + "include": [ + "../../types.d.ts", + "src" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file From c2ca9eaf267122e77488ebefe77c3b81840a3105 Mon Sep 17 00:00:00 2001 From: sadcitizen Date: Mon, 8 Jun 2026 12:35:03 +0500 Subject: [PATCH 03/60] feat(http): add constants --- .../http-client/src/constants/httpMethods.ts | 11 ++++ .../src/constants/httpStatusCodes.ts | 64 +++++++++++++++++++ services/http-client/src/constants/index.ts | 2 + services/http-client/src/types/httpMethod.ts | 3 + .../http-client/src/types/httpStatusCode.ts | 3 + services/http-client/src/types/index.ts | 2 + 6 files changed, 85 insertions(+) create mode 100644 services/http-client/src/constants/httpMethods.ts create mode 100644 services/http-client/src/constants/httpStatusCodes.ts create mode 100644 services/http-client/src/constants/index.ts create mode 100644 services/http-client/src/types/httpMethod.ts create mode 100644 services/http-client/src/types/httpStatusCode.ts create mode 100644 services/http-client/src/types/index.ts diff --git a/services/http-client/src/constants/httpMethods.ts b/services/http-client/src/constants/httpMethods.ts new file mode 100644 index 00000000..c39fb1bc --- /dev/null +++ b/services/http-client/src/constants/httpMethods.ts @@ -0,0 +1,11 @@ +export const HTTP_METHODS = { + GET: 'GET', + HEAD: 'HEAD', + POST: 'POST', + PUT: 'PUT', + DELETE: 'DELETE', + CONNECT: 'CONNECT', + OPTIONS: 'OPTIONS', + TRACE: 'TRACE', + PATCH: 'PATCH' +} as const; \ No newline at end of file diff --git a/services/http-client/src/constants/httpStatusCodes.ts b/services/http-client/src/constants/httpStatusCodes.ts new file mode 100644 index 00000000..082ce04d --- /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; \ No newline at end of file diff --git a/services/http-client/src/constants/index.ts b/services/http-client/src/constants/index.ts new file mode 100644 index 00000000..c6e96c35 --- /dev/null +++ b/services/http-client/src/constants/index.ts @@ -0,0 +1,2 @@ +export * from './httpMethods'; +export * from './httpStatusCodes'; \ No newline at end of file diff --git a/services/http-client/src/types/httpMethod.ts b/services/http-client/src/types/httpMethod.ts new file mode 100644 index 00000000..ea1ab98f --- /dev/null +++ b/services/http-client/src/types/httpMethod.ts @@ -0,0 +1,3 @@ +import { HTTP_METHODS } from '../constants'; + +export type HttpMethod = typeof HTTP_METHODS[keyof typeof HTTP_METHODS]; \ No newline at end of file diff --git a/services/http-client/src/types/httpStatusCode.ts b/services/http-client/src/types/httpStatusCode.ts new file mode 100644 index 00000000..55debf9d --- /dev/null +++ b/services/http-client/src/types/httpStatusCode.ts @@ -0,0 +1,3 @@ +import { HTTP_STATUS_CODES } from '../constants'; + +export type HttpStatusCode = typeof HTTP_STATUS_CODES[keyof typeof HTTP_STATUS_CODES]; \ No newline at end of file diff --git a/services/http-client/src/types/index.ts b/services/http-client/src/types/index.ts new file mode 100644 index 00000000..b72ecff0 --- /dev/null +++ b/services/http-client/src/types/index.ts @@ -0,0 +1,2 @@ +export * from './httpMethod'; +export * from './httpStatusCode'; \ No newline at end of file From 60562958f2a8966de30b35518ef225d7a9e742db Mon Sep 17 00:00:00 2001 From: sadcitizen Date: Tue, 9 Jun 2026 11:00:13 +0500 Subject: [PATCH 04/60] feat(http): add basic errors --- services/http-client/src/errors/AbortError.ts | 8 ++++++++ services/http-client/src/errors/HttpError.ts | 6 ++++++ services/http-client/src/errors/NetworkError.ts | 8 ++++++++ services/http-client/src/errors/TimeoutError.ts | 8 ++++++++ services/http-client/src/errors/index.ts | 4 ++++ 5 files changed, 34 insertions(+) create mode 100644 services/http-client/src/errors/AbortError.ts create mode 100644 services/http-client/src/errors/HttpError.ts create mode 100644 services/http-client/src/errors/NetworkError.ts create mode 100644 services/http-client/src/errors/TimeoutError.ts create mode 100644 services/http-client/src/errors/index.ts diff --git a/services/http-client/src/errors/AbortError.ts b/services/http-client/src/errors/AbortError.ts new file mode 100644 index 00000000..3a295a9e --- /dev/null +++ b/services/http-client/src/errors/AbortError.ts @@ -0,0 +1,8 @@ +import { HttpError } from './HttpError'; + +export class AbortError extends HttpError { + constructor(message: string) { + super(message); + this.name = 'AbortError'; + } +} diff --git a/services/http-client/src/errors/HttpError.ts b/services/http-client/src/errors/HttpError.ts new file mode 100644 index 00000000..3c883495 --- /dev/null +++ b/services/http-client/src/errors/HttpError.ts @@ -0,0 +1,6 @@ +export class HttpError extends Error { + constructor(message: string) { + super(message); + this.name = 'HttpError'; + } +} diff --git a/services/http-client/src/errors/NetworkError.ts b/services/http-client/src/errors/NetworkError.ts new file mode 100644 index 00000000..2e095661 --- /dev/null +++ b/services/http-client/src/errors/NetworkError.ts @@ -0,0 +1,8 @@ +import { HttpError } from './HttpError'; + +export class NetworkError extends HttpError { + constructor(message: string) { + super(message); + this.name = 'NetworkError'; + } +} diff --git a/services/http-client/src/errors/TimeoutError.ts b/services/http-client/src/errors/TimeoutError.ts new file mode 100644 index 00000000..e28d2bc6 --- /dev/null +++ b/services/http-client/src/errors/TimeoutError.ts @@ -0,0 +1,8 @@ +import { HttpError } from './HttpError'; + +export class TimeoutError extends HttpError { + constructor(message: string) { + super(message); + this.name = '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..428aeea9 --- /dev/null +++ b/services/http-client/src/errors/index.ts @@ -0,0 +1,4 @@ +export { AbortError } from './AbortError'; +export { HttpError } from './HttpError'; +export { NetworkError } from './NetworkError'; +export { TimeoutError } from './TimeoutError'; From 4775f9665d15047fa4e86bbc7489c7833e5505dd Mon Sep 17 00:00:00 2001 From: sadcitizen Date: Fri, 12 Jun 2026 14:13:46 +0500 Subject: [PATCH 05/60] feat(http): empty parse error --- services/http-client/src/errors/ParseError.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 services/http-client/src/errors/ParseError.ts diff --git a/services/http-client/src/errors/ParseError.ts b/services/http-client/src/errors/ParseError.ts new file mode 100644 index 00000000..c9368160 --- /dev/null +++ b/services/http-client/src/errors/ParseError.ts @@ -0,0 +1,8 @@ +import { HttpError } from './HttpError'; + +export class ParseError extends HttpError { + constructor(message: string) { + super(message); + this.name = 'ParseError'; + } +} From c088370f2330c5b318dd0d5564fd33605cc06f2d Mon Sep 17 00:00:00 2001 From: sadcitizen Date: Sat, 20 Jun 2026 15:02:31 +0500 Subject: [PATCH 06/60] feat(http): types --- .../http-client/src/types/HttpClientAdapter.ts | 6 ++++++ .../http-client/src/types/HttpClientOptions.ts | 9 +++++++++ services/http-client/src/types/HttpHeaders.ts | 1 + services/http-client/src/types/HttpMethod.ts | 3 +++ services/http-client/src/types/HttpParams.ts | 1 + .../http-client/src/types/HttpRequestConfig.ts | 16 ++++++++++++++++ services/http-client/src/types/HttpResponse.ts | 11 +++++++++++ .../http-client/src/types/HttpResponseType.ts | 1 + services/http-client/src/types/HttpStatusCode.ts | 3 +++ services/http-client/src/types/httpMethod.ts | 3 --- services/http-client/src/types/httpStatusCode.ts | 3 --- services/http-client/src/types/index.ts | 11 +++++++++-- 12 files changed, 60 insertions(+), 8 deletions(-) create mode 100644 services/http-client/src/types/HttpClientAdapter.ts create mode 100644 services/http-client/src/types/HttpClientOptions.ts create mode 100644 services/http-client/src/types/HttpHeaders.ts create mode 100644 services/http-client/src/types/HttpMethod.ts create mode 100644 services/http-client/src/types/HttpParams.ts create mode 100644 services/http-client/src/types/HttpRequestConfig.ts create mode 100644 services/http-client/src/types/HttpResponse.ts create mode 100644 services/http-client/src/types/HttpResponseType.ts create mode 100644 services/http-client/src/types/HttpStatusCode.ts delete mode 100644 services/http-client/src/types/httpMethod.ts delete mode 100644 services/http-client/src/types/httpStatusCode.ts 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..67e5b92b --- /dev/null +++ b/services/http-client/src/types/HttpClientOptions.ts @@ -0,0 +1,9 @@ +import { IHttpClientAdapter } from './HttpClientAdapter'; +import { THttpHeaders } from './HttpHeaders'; + +export interface IHttpClientOptions { + adapter: IHttpClientAdapter; + baseURL?: string; + headers?: THttpHeaders; + timeout?: number; +} \ No newline at end of file diff --git a/services/http-client/src/types/HttpHeaders.ts b/services/http-client/src/types/HttpHeaders.ts new file mode 100644 index 00000000..54652098 --- /dev/null +++ b/services/http-client/src/types/HttpHeaders.ts @@ -0,0 +1 @@ +export type THttpHeaders = Record; \ No newline at end of file diff --git a/services/http-client/src/types/HttpMethod.ts b/services/http-client/src/types/HttpMethod.ts new file mode 100644 index 00000000..6e4d6d94 --- /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]; \ No newline at end of file diff --git a/services/http-client/src/types/HttpParams.ts b/services/http-client/src/types/HttpParams.ts new file mode 100644 index 00000000..7429490e --- /dev/null +++ b/services/http-client/src/types/HttpParams.ts @@ -0,0 +1 @@ +export type THttpParams = Record; \ No newline at end of file diff --git a/services/http-client/src/types/HttpRequestConfig.ts b/services/http-client/src/types/HttpRequestConfig.ts new file mode 100644 index 00000000..fdb2a3ec --- /dev/null +++ b/services/http-client/src/types/HttpRequestConfig.ts @@ -0,0 +1,16 @@ +import { THttpMethod } from './HttpMethod'; +import { THttpHeaders } from './HttpHeaders'; +import { THttpParams } from './HttpParams'; +import { THttpResponseType } from './HttpResponseType'; + +export interface IHttpRequestConfig { + url: string; + method: THttpMethod; + baseURL?: string; + headers?: THttpHeaders; + params?: THttpParams; + data?: TData; + signal?: AbortSignal; + timeout?: number; + responseType?: THttpResponseType; +} \ No newline at end of file diff --git a/services/http-client/src/types/HttpResponse.ts b/services/http-client/src/types/HttpResponse.ts new file mode 100644 index 00000000..e272b827 --- /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; +} \ No newline at end of file diff --git a/services/http-client/src/types/HttpResponseType.ts b/services/http-client/src/types/HttpResponseType.ts new file mode 100644 index 00000000..038da2e2 --- /dev/null +++ b/services/http-client/src/types/HttpResponseType.ts @@ -0,0 +1 @@ +export type THttpResponseType = 'json' | 'text' | 'blob' | 'arraybuffer'; \ No newline at end of file diff --git a/services/http-client/src/types/HttpStatusCode.ts b/services/http-client/src/types/HttpStatusCode.ts new file mode 100644 index 00000000..724312bb --- /dev/null +++ b/services/http-client/src/types/HttpStatusCode.ts @@ -0,0 +1,3 @@ +import { HTTP_STATUS_CODES } from '../constants'; + +export type THttpStatusCode = typeof HTTP_STATUS_CODES[keyof typeof HTTP_STATUS_CODES]; \ No newline at end of file diff --git a/services/http-client/src/types/httpMethod.ts b/services/http-client/src/types/httpMethod.ts deleted file mode 100644 index ea1ab98f..00000000 --- a/services/http-client/src/types/httpMethod.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { HTTP_METHODS } from '../constants'; - -export type HttpMethod = typeof HTTP_METHODS[keyof typeof HTTP_METHODS]; \ No newline at end of file diff --git a/services/http-client/src/types/httpStatusCode.ts b/services/http-client/src/types/httpStatusCode.ts deleted file mode 100644 index 55debf9d..00000000 --- a/services/http-client/src/types/httpStatusCode.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { HTTP_STATUS_CODES } from '../constants'; - -export type HttpStatusCode = typeof HTTP_STATUS_CODES[keyof typeof HTTP_STATUS_CODES]; \ No newline at end of file diff --git a/services/http-client/src/types/index.ts b/services/http-client/src/types/index.ts index b72ecff0..cd6c9575 100644 --- a/services/http-client/src/types/index.ts +++ b/services/http-client/src/types/index.ts @@ -1,2 +1,9 @@ -export * from './httpMethod'; -export * from './httpStatusCode'; \ No newline at end of file +export * from './HttpMethod'; +export * from './HttpStatusCode'; +export * from './HttpClientAdapter'; +export * from './HttpClientOptions'; +export * from './HttpRequestConfig'; +export * from './HttpResponse'; +export * from './HttpHeaders'; +export * from './HttpParams'; +export * from './HttpResponseType'; \ No newline at end of file From a59d9cdcc3a790ae981dfd1c38ab7d1ce995b321 Mon Sep 17 00:00:00 2001 From: sadcitizen Date: Sat, 20 Jun 2026 16:45:24 +0500 Subject: [PATCH 07/60] feat(http): wip --- .../src/constants/httpResponseTypes.ts | 8 ++++++++ services/http-client/src/constants/index.ts | 3 ++- .../src/errors/RequestBuilderError.ts | 8 ++++++++ services/http-client/src/types/HttpParams.ts | 4 +++- .../src/types/HttpRequestConfig.ts | 20 +++++++++---------- .../http-client/src/types/HttpResponseType.ts | 4 +++- 6 files changed, 34 insertions(+), 13 deletions(-) create mode 100644 services/http-client/src/constants/httpResponseTypes.ts create mode 100644 services/http-client/src/errors/RequestBuilderError.ts diff --git a/services/http-client/src/constants/httpResponseTypes.ts b/services/http-client/src/constants/httpResponseTypes.ts new file mode 100644 index 00000000..505d8970 --- /dev/null +++ b/services/http-client/src/constants/httpResponseTypes.ts @@ -0,0 +1,8 @@ +export const HTTP_RESPONSE_TYPES = { + ARRAY_BUFFER: 'arrayBuffer', + BLOB: 'blob', + FORM_DATA: 'formData', + JSON: 'json', + STREAM: 'stream', + TEXT: 'text' +}; diff --git a/services/http-client/src/constants/index.ts b/services/http-client/src/constants/index.ts index c6e96c35..a7417248 100644 --- a/services/http-client/src/constants/index.ts +++ b/services/http-client/src/constants/index.ts @@ -1,2 +1,3 @@ export * from './httpMethods'; -export * from './httpStatusCodes'; \ No newline at end of file +export * from './httpStatusCodes'; +export * from './httpResponseTypes'; diff --git a/services/http-client/src/errors/RequestBuilderError.ts b/services/http-client/src/errors/RequestBuilderError.ts new file mode 100644 index 00000000..ef1601ab --- /dev/null +++ b/services/http-client/src/errors/RequestBuilderError.ts @@ -0,0 +1,8 @@ +export class RequestBuilderError extends Error { + constructor(message: string) { + super(message); + this.name = this.constructor.name; + + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/services/http-client/src/types/HttpParams.ts b/services/http-client/src/types/HttpParams.ts index 7429490e..e1bf0bc4 100644 --- a/services/http-client/src/types/HttpParams.ts +++ b/services/http-client/src/types/HttpParams.ts @@ -1 +1,3 @@ -export type THttpParams = Record; \ No newline at end of file +export type THttpParamValue = string | string[]; + +export type THttpParams = Record; diff --git a/services/http-client/src/types/HttpRequestConfig.ts b/services/http-client/src/types/HttpRequestConfig.ts index fdb2a3ec..e8e3277d 100644 --- a/services/http-client/src/types/HttpRequestConfig.ts +++ b/services/http-client/src/types/HttpRequestConfig.ts @@ -4,13 +4,13 @@ import { THttpParams } from './HttpParams'; import { THttpResponseType } from './HttpResponseType'; export interface IHttpRequestConfig { - url: string; - method: THttpMethod; - baseURL?: string; - headers?: THttpHeaders; - params?: THttpParams; - data?: TData; - signal?: AbortSignal; - timeout?: number; - responseType?: THttpResponseType; -} \ No newline at end of file + 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 responseType?: THttpResponseType; +} diff --git a/services/http-client/src/types/HttpResponseType.ts b/services/http-client/src/types/HttpResponseType.ts index 038da2e2..7324ae38 100644 --- a/services/http-client/src/types/HttpResponseType.ts +++ b/services/http-client/src/types/HttpResponseType.ts @@ -1 +1,3 @@ -export type THttpResponseType = 'json' | 'text' | 'blob' | 'arraybuffer'; \ No newline at end of file +import { HTTP_RESPONSE_TYPES } from '../constants'; + +export type THttpResponseType = (typeof HTTP_RESPONSE_TYPES)[keyof typeof HTTP_RESPONSE_TYPES]; From d1a0500982108acf54163a424a9773a318dd1c64 Mon Sep 17 00:00:00 2001 From: sadcitizen Date: Sat, 20 Jun 2026 16:50:13 +0500 Subject: [PATCH 08/60] feat(http): wip --- .../src/core/HttpRequestBuilder.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 services/http-client/src/core/HttpRequestBuilder.ts diff --git a/services/http-client/src/core/HttpRequestBuilder.ts b/services/http-client/src/core/HttpRequestBuilder.ts new file mode 100644 index 00000000..e5c944ce --- /dev/null +++ b/services/http-client/src/core/HttpRequestBuilder.ts @@ -0,0 +1,109 @@ +import { HTTP_RESPONSE_TYPES } from '../constants'; +import { RequestBuilderError } from '../errors'; +import { + THttpMethod, + IHttpRequestConfig, + IHttpResponse, + THttpHeaders, + THttpParams, + THttpResponseType, + THttpParamValue +} from '../types'; + +type TExecutor = (config: IHttpRequestConfig) => Promise>; + +export class HttpRequestBuilder { + private executor: TExecutor; + private config: IHttpRequestConfig; + + constructor(executor: TExecutor, method: THttpMethod, url: string) { + this.executor = executor; + this.config = { method, url }; + } + + private mutateConfig(partial: Partial): this { + this.config = { ...this.config, ...partial }; + + return this; + } + + public baseURL(value: string): this { + if (!value) { + throw new RequestBuilderError('Base URL must be a non-empty string'); + } + + return this.mutateConfig({ baseURL: value }); + } + + public header(key: string, value: string): this { + if (!key) { + throw new RequestBuilderError('Header key must be a non-empty string'); + } + + return this.mutateConfig({ headers: { ...this.config.headers, [key]: value } }); + } + + public headers(headers: THttpHeaders): this { + if (!headers || typeof headers !== 'object') { + throw new RequestBuilderError('Headers must be an object'); + } + + return this.mutateConfig({ headers: { ...this.config.headers, ...headers } }); + } + + public param(key: string, value: THttpParamValue): this { + if (!key) { + throw new RequestBuilderError('Param key must be a non-empty string'); + } + + return this.mutateConfig({ params: { ...this.config.params, [key]: value } }); + } + + public params(params: THttpParams): this { + if (!params || typeof params !== 'object') { + throw new RequestBuilderError('Params must be an object'); + } + + return this.mutateConfig({ params: { ...this.config.params, ...params } }); + } + + public body(data: unknown): this { + return this.mutateConfig({ data }); + } + + public signal(signal: AbortSignal): this { + return this.mutateConfig({ signal }); + } + + public timeout(timeout: number): this { + if (!Number.isFinite(timeout) || timeout < 0) { + throw new RequestBuilderError('Timeout must be a finite non-negative number'); + } + + return this.mutateConfig({ timeout }); + } + + public bearer(token: string): this { + if (!token) { + throw new RequestBuilderError('Bearer token must be a non-empty string'); + } + + return this.header('Authorization', `Bearer ${token}`); + } + + public responseType(responseType: THttpResponseType): this { + if (!Object.values(HTTP_RESPONSE_TYPES).includes(responseType)) { + throw new RequestBuilderError('Response type must be a valid HTTP response type'); + } + + return this.mutateConfig({ responseType }); + } + + public execute(): Promise> { + if (!this.config.url) { + throw new RequestBuilderError('URL must be set before executing'); + } + + return this.executor(this.config) as Promise>; + } +} From 13e32ec8cf8e8cc9bea499f628e5638875c7de70 Mon Sep 17 00:00:00 2001 From: sadcitizen Date: Sat, 20 Jun 2026 16:57:35 +0500 Subject: [PATCH 09/60] feat(http): wip --- services/http-client/src/errors/AbortError.ts | 8 ++++---- services/http-client/src/errors/HttpError.ts | 14 ++++++++++++-- services/http-client/src/errors/NetworkError.ts | 8 ++++---- services/http-client/src/errors/ParseError.ts | 8 ++++---- services/http-client/src/errors/TimeoutError.ts | 8 ++++---- services/http-client/src/errors/index.ts | 2 ++ 6 files changed, 30 insertions(+), 18 deletions(-) diff --git a/services/http-client/src/errors/AbortError.ts b/services/http-client/src/errors/AbortError.ts index 3a295a9e..dd2a3ded 100644 --- a/services/http-client/src/errors/AbortError.ts +++ b/services/http-client/src/errors/AbortError.ts @@ -1,8 +1,8 @@ -import { HttpError } from './HttpError'; - -export class AbortError extends HttpError { +export class AbortError extends Error { constructor(message: string) { super(message); - this.name = 'AbortError'; + this.name = this.constructor.name; + + Object.setPrototypeOf(this, new.target.prototype); } } diff --git a/services/http-client/src/errors/HttpError.ts b/services/http-client/src/errors/HttpError.ts index 3c883495..db5bfcc7 100644 --- a/services/http-client/src/errors/HttpError.ts +++ b/services/http-client/src/errors/HttpError.ts @@ -1,6 +1,16 @@ +import { THttpStatusCode } from '../types'; + export class HttpError extends Error { - constructor(message: string) { + public readonly statusCode?: THttpStatusCode; + public readonly data?: unknown; + + constructor(message: string, statusCode?: THttpStatusCode, data?: unknown) { super(message); - this.name = 'HttpError'; + this.name = this.constructor.name; + + this.statusCode = statusCode; + this.data = data; + + Object.setPrototypeOf(this, new.target.prototype); } } diff --git a/services/http-client/src/errors/NetworkError.ts b/services/http-client/src/errors/NetworkError.ts index 2e095661..b3b0f52b 100644 --- a/services/http-client/src/errors/NetworkError.ts +++ b/services/http-client/src/errors/NetworkError.ts @@ -1,8 +1,8 @@ -import { HttpError } from './HttpError'; - -export class NetworkError extends HttpError { +export class NetworkError extends Error { constructor(message: string) { super(message); - this.name = 'NetworkError'; + this.name = this.constructor.name; + + Object.setPrototypeOf(this, new.target.prototype); } } diff --git a/services/http-client/src/errors/ParseError.ts b/services/http-client/src/errors/ParseError.ts index c9368160..64762876 100644 --- a/services/http-client/src/errors/ParseError.ts +++ b/services/http-client/src/errors/ParseError.ts @@ -1,8 +1,8 @@ -import { HttpError } from './HttpError'; - -export class ParseError extends HttpError { +export class ParseError extends Error { constructor(message: string) { super(message); - this.name = 'ParseError'; + this.name = this.constructor.name; + + Object.setPrototypeOf(this, new.target.prototype); } } diff --git a/services/http-client/src/errors/TimeoutError.ts b/services/http-client/src/errors/TimeoutError.ts index e28d2bc6..7d6e84b0 100644 --- a/services/http-client/src/errors/TimeoutError.ts +++ b/services/http-client/src/errors/TimeoutError.ts @@ -1,8 +1,8 @@ -import { HttpError } from './HttpError'; - -export class TimeoutError extends HttpError { +export class TimeoutError extends Error { constructor(message: string) { super(message); - this.name = 'TimeoutError'; + this.name = this.constructor.name; + + Object.setPrototypeOf(this, new.target.prototype); } } diff --git a/services/http-client/src/errors/index.ts b/services/http-client/src/errors/index.ts index 428aeea9..a9a42390 100644 --- a/services/http-client/src/errors/index.ts +++ b/services/http-client/src/errors/index.ts @@ -1,4 +1,6 @@ export { AbortError } from './AbortError'; export { HttpError } from './HttpError'; export { NetworkError } from './NetworkError'; +export { ParseError } from './ParseError'; +export { RequestBuilderError } from './RequestBuilderError'; export { TimeoutError } from './TimeoutError'; From f885bfb8521e9605453c8ec574feac49f0a8b410 Mon Sep 17 00:00:00 2001 From: sadcitizen Date: Sat, 20 Jun 2026 19:57:51 +0500 Subject: [PATCH 10/60] feat(http): wip --- package-lock.json | 432 +++++++++++++++++- services/http-client/package.json | 6 +- .../http-client/src/adapters/FetchAdapter.ts | 170 +++++++ .../http-client/src/adapters/XhrAdapter.ts | 200 ++++++++ services/http-client/src/adapters/index.ts | 2 + services/http-client/src/core/HttpClient.ts | 70 +++ .../src/core/HttpRequestBuilder.ts | 115 ++++- services/http-client/src/core/index.ts | 2 + services/http-client/src/index.ts | 6 +- .../src/types/HttpClientOptions.ts | 4 +- .../src/types/HttpRequestConfig.ts | 2 +- .../http-client/src/types/HttpResponse.ts | 4 +- 12 files changed, 1004 insertions(+), 9 deletions(-) create mode 100644 services/http-client/src/adapters/FetchAdapter.ts create mode 100644 services/http-client/src/adapters/XhrAdapter.ts create mode 100644 services/http-client/src/adapters/index.ts create mode 100644 services/http-client/src/core/HttpClient.ts create mode 100644 services/http-client/src/core/index.ts diff --git a/package-lock.json b/package-lock.json index ab5d5097..a190fe93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -223,7 +223,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 +774,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 +2506,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 +2617,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", @@ -2857,6 +2961,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 +3047,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 +5045,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 +6666,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 +6872,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 +8992,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 +9026,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 +9599,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 +9734,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 +10389,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", @@ -11514,6 +11764,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 +12236,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 +12496,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 +14002,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", @@ -14075,6 +14450,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 +14799,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 +14857,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 +15666,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 +16260,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 +17192,14 @@ "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" + } + }, "services/local-storage": { "name": "@byndyusoft-ui/local-storage", "version": "0.1.0", diff --git a/services/http-client/package.json b/services/http-client/package.json index 7bc49f69..23ad6ea4 100644 --- a/services/http-client/package.json +++ b/services/http-client/package.json @@ -19,12 +19,16 @@ "scripts": { "build": "tsc", "clean": "rimraf dist && rimraf .turbo && rimraf node_modules && rimraf package-lock.json", - "lint": "eslint src --config ../../eslint.config.js" + "lint": "eslint src --config ../../eslint.config.js", + "test": "vitest run --root ../../ services/http-client" }, "bugs": { "url": "https://github.com/Byndyusoft/ui/issues" }, "publishConfig": { "access": "public" + }, + "devDependencies": { + "msw": "^2.4.9" } } \ No newline at end of file diff --git a/services/http-client/src/adapters/FetchAdapter.ts b/services/http-client/src/adapters/FetchAdapter.ts new file mode 100644 index 00000000..02ae5c9a --- /dev/null +++ b/services/http-client/src/adapters/FetchAdapter.ts @@ -0,0 +1,170 @@ +import { HTTP_METHODS } from '../constants'; +import { HttpError } from '../errors/HttpError'; +import { NetworkError } from '../errors/NetworkError'; +import { TimeoutError } from '../errors/TimeoutError'; +import { AbortError } from '../errors/AbortError'; +import { + IHttpClientAdapter, + IHttpRequestConfig, + IHttpResponse, + THttpStatusCode, + THttpHeaders, + THttpParams, + THttpResponseType +} from '../types'; + +function buildUrl(baseURL: string | undefined, url: string, params?: THttpParams): string { + let fullUrl = baseURL ? `${baseURL}${url}` : url; + + if (params && Object.keys(params).length > 0) { + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + const values = Array.isArray(value) ? value : [value]; + for (const v of values) { + searchParams.append(key, v); + } + } + const separator = fullUrl.includes('?') ? '&' : '?'; + fullUrl += separator + searchParams.toString(); + } + + return fullUrl; +} + +function extractResponseHeaders(headers: Headers): THttpHeaders { + const result: THttpHeaders = {}; + headers.forEach((value, key) => { + result[key] = value; + }); + return result; +} + +async function parseResponseBody(response: Response, responseType?: THttpResponseType): Promise { + if (response.status === 204 || response.headers.get('content-length') === '0') { + return undefined as T; + } + + switch (responseType) { + case 'text': + return response.text() as Promise; + case 'blob': + return response.blob() as Promise; + case 'arraybuffer': + return response.arrayBuffer() as Promise; + case 'json': + default: { + const text = await response.text(); + if (!text) { + return undefined as T; + } + return JSON.parse(text) as T; + } + } +} + +export class FetchAdapter implements IHttpClientAdapter { + async request(config: IHttpRequestConfig): Promise> { + const { + url, + method, + headers = {}, + params, + data, + signal: userSignal, + timeout, + responseType, + baseUrl: baseURL + } = config; + + const fullUrl = buildUrl(baseURL ?? undefined, url, params); + + const requestHeaders: THttpHeaders = { ...headers }; + let body: BodyInit | undefined; + + if (data !== undefined && method !== HTTP_METHODS.GET && method !== HTTP_METHODS.HEAD) { + if (typeof data === 'string' || data instanceof ArrayBuffer || data instanceof Blob) { + body = data as BodyInit; + } else { + body = JSON.stringify(data); + if (!requestHeaders['Content-Type']) { + requestHeaders['Content-Type'] = 'application/json'; + } + } + } + + let ownController: AbortController | undefined; + let timeoutId: ReturnType | undefined; + + if (timeout) { + ownController = new AbortController(); + timeoutId = setTimeout(() => ownController!.abort(), timeout); + + if (userSignal) { + if (userSignal.aborted) { + clearTimeout(timeoutId); + throw new AbortError('Request was aborted'); + } + userSignal.addEventListener('abort', () => ownController!.abort(), { once: true }); + } + } + + const requestSignal = ownController?.signal ?? userSignal; + + try { + const response = await fetch(fullUrl, { + method, + headers: requestHeaders, + body, + signal: requestSignal + }); + + if (!response.ok) { + let errorData: unknown; + try { + const text = await response.text(); + try { + errorData = JSON.parse(text); + } catch { + errorData = text || undefined; + } + } catch { + errorData = undefined; + } + + throw new HttpError( + `Request failed with status code ${response.status}`, + response.status as THttpStatusCode, + errorData + ); + } + + const responseData = await parseResponseBody(response, responseType); + + return { + data: responseData, + status: response.status as THttpStatusCode, + statusText: response.statusText, + headers: extractResponseHeaders(response.headers), + config + }; + } catch (error) { + if (error instanceof HttpError) { + throw error; + } + + if (userSignal?.aborted) { + throw new AbortError('Request was aborted'); + } + + if (timeout && error instanceof DOMException && error.name === 'AbortError') { + throw new TimeoutError(`Request timed out after ${timeout}ms`); + } + + throw new NetworkError((error as Error).message); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } + } +} diff --git a/services/http-client/src/adapters/XhrAdapter.ts b/services/http-client/src/adapters/XhrAdapter.ts new file mode 100644 index 00000000..cf5669f0 --- /dev/null +++ b/services/http-client/src/adapters/XhrAdapter.ts @@ -0,0 +1,200 @@ +import { HTTP_METHODS } from '../constants'; +import { HttpError } from '../errors/HttpError'; +import { NetworkError } from '../errors/NetworkError'; +import { TimeoutError } from '../errors/TimeoutError'; +import { AbortError } from '../errors/AbortError'; +import { + IHttpClientAdapter, + IHttpRequestConfig, + IHttpResponse, + THttpStatusCode, + THttpHeaders, + THttpParams, + THttpResponseType +} from '../types'; + +function buildUrl(baseURL: string | undefined, url: string, params?: THttpParams): string { + let fullUrl = baseURL ? `${baseURL}${url}` : url; + + if (params && Object.keys(params).length > 0) { + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + const values = Array.isArray(value) ? value : [value]; + for (const v of values) { + searchParams.append(key, v); + } + } + const separator = fullUrl.includes('?') ? '&' : '?'; + fullUrl += separator + searchParams.toString(); + } + + return fullUrl; +} + +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; +} + +function getResponseBody(xhr: XMLHttpRequest, responseType?: THttpResponseType): unknown { + if (xhr.status === 204) { + return undefined; + } + + switch (responseType) { + case 'arraybuffer': + case 'blob': + return xhr.response; + case 'text': + return xhr.response; + case 'json': + default: { + const text = xhr.response as string; + if (!text) { + return undefined; + } + return JSON.parse(text); + } + } +} + +export class XhrAdapter implements IHttpClientAdapter { + request(config: IHttpRequestConfig): Promise> { + const { + url, + method, + headers = {}, + params, + data, + signal: userSignal, + timeout, + responseType, + baseUrl: baseURL + } = config; + + const fullUrl = buildUrl(baseURL ?? undefined, url, params); + + return new Promise>((resolve, reject) => { + if (userSignal?.aborted) { + reject(new AbortError('Request was aborted')); + return; + } + + const xhr = new XMLHttpRequest(); + xhr.open(method, fullUrl, true); + + if (timeout !== undefined && timeout > 0) { + xhr.timeout = timeout; + } + + if (responseType === 'arraybuffer') { + xhr.responseType = 'arraybuffer'; + } else if (responseType === 'blob') { + xhr.responseType = 'blob'; + } else { + xhr.responseType = 'text'; + } + + const requestHeaders: THttpHeaders = { ...headers }; + if (data !== undefined && method !== HTTP_METHODS.GET && method !== HTTP_METHODS.HEAD) { + if (typeof data !== 'string' && !(data instanceof ArrayBuffer) && !(data instanceof Blob)) { + if (!requestHeaders['Content-Type']) { + requestHeaders['Content-Type'] = 'application/json'; + } + } + } + for (const [key, value] of Object.entries(requestHeaders)) { + xhr.setRequestHeader(key, value); + } + + let body: string | ArrayBuffer | Blob | undefined; + if (data !== undefined && method !== HTTP_METHODS.GET && method !== HTTP_METHODS.HEAD) { + if (typeof data === 'string' || data instanceof ArrayBuffer || data instanceof Blob) { + body = data; + } else { + body = JSON.stringify(data); + } + } + + const onAbort = (): void => xhr.abort(); + if (userSignal) { + userSignal.addEventListener('abort', onAbort, { once: true }); + } + + const cleanup = (): void => { + if (userSignal) { + userSignal.removeEventListener('abort', onAbort); + } + }; + + xhr.onload = () => { + cleanup(); + + if (xhr.status >= 200 && xhr.status < 300) { + try { + const responseData = getResponseBody(xhr, responseType) as T; + resolve({ + data: responseData, + status: xhr.status as THttpStatusCode, + statusText: xhr.statusText, + headers: parseResponseHeaders(xhr.getAllResponseHeaders()), + config + }); + } catch (error) { + reject(new NetworkError((error as Error).message)); + } + } else { + let errorData: unknown; + try { + if (xhr.responseType === 'text') { + const text = xhr.responseText; + if (text) { + try { + errorData = JSON.parse(text); + } catch { + errorData = text; + } + } + } + } catch { + errorData = undefined; + } + + reject( + new HttpError( + `Request failed with status code ${xhr.status}`, + xhr.status as THttpStatusCode, + errorData + ) + ); + } + }; + + xhr.onerror = () => { + cleanup(); + reject(new NetworkError('Network request failed')); + }; + + xhr.onabort = () => { + cleanup(); + reject(new AbortError('Request was aborted')); + }; + + xhr.ontimeout = () => { + cleanup(); + reject(new TimeoutError(`Request timed out after ${timeout}ms`)); + }; + + xhr.send(body); + }); + } +} diff --git a/services/http-client/src/adapters/index.ts b/services/http-client/src/adapters/index.ts new file mode 100644 index 00000000..ca711918 --- /dev/null +++ b/services/http-client/src/adapters/index.ts @@ -0,0 +1,2 @@ +export { FetchAdapter } from './FetchAdapter'; +export { XhrAdapter } from './XhrAdapter'; \ No newline at end of file diff --git a/services/http-client/src/core/HttpClient.ts b/services/http-client/src/core/HttpClient.ts new file mode 100644 index 00000000..91a588dd --- /dev/null +++ b/services/http-client/src/core/HttpClient.ts @@ -0,0 +1,70 @@ +import { HTTP_METHODS } from '../constants'; +import { THttpMethod, IHttpRequestConfig, IHttpResponse, IHttpClientOptions, IHttpClientAdapter } from '../types'; +import { HttpRequestBuilder } from './HttpRequestBuilder'; + +export class HttpClient { + private readonly adapter: IHttpClientAdapter; + private readonly defaultConfig: Pick; + + constructor(options: IHttpClientOptions) { + this.adapter = options.adapter; + + this.defaultConfig = { + baseUrl: options.baseUrl, + headers: options.headers, + timeout: options.timeout + }; + } + + private request(method: THttpMethod, url: string): HttpRequestBuilder { + return new HttpRequestBuilder(config => this.execute(config), 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 connect(url: string): HttpRequestBuilder { + return this.request(HTTP_METHODS.CONNECT, url); + } + + public options(url: string): HttpRequestBuilder { + return this.request(HTTP_METHODS.OPTIONS, url); + } + + public trace(url: string): HttpRequestBuilder { + return this.request(HTTP_METHODS.TRACE, url); + } + + public patch(url: string): HttpRequestBuilder { + return this.request(HTTP_METHODS.PATCH, url); + } + + private execute(config: IHttpRequestConfig): Promise> { + const mergedConfig: IHttpRequestConfig = { + ...config, + baseUrl: config.baseUrl ?? this.defaultConfig.baseUrl, + timeout: config.timeout ?? this.defaultConfig.timeout, + headers: { ...this.defaultConfig.headers, ...config.headers }, + params: { ...this.defaultConfig.params, ...config.params } + }; + + return this.adapter.request(mergedConfig); + } +} diff --git a/services/http-client/src/core/HttpRequestBuilder.ts b/services/http-client/src/core/HttpRequestBuilder.ts index e5c944ce..29c84840 100644 --- a/services/http-client/src/core/HttpRequestBuilder.ts +++ b/services/http-client/src/core/HttpRequestBuilder.ts @@ -32,7 +32,7 @@ export class HttpRequestBuilder { throw new RequestBuilderError('Base URL must be a non-empty string'); } - return this.mutateConfig({ baseURL: value }); + return this.mutateConfig({ baseUrl: value }); } public header(key: string, value: string): this { @@ -68,6 +68,15 @@ export class HttpRequestBuilder { } public body(data: unknown): this { + if (this.config.method === 'GET' || this.config.method === 'HEAD') { + // Возможно ещё и для DELETE + throw new RequestBuilderError('Body is not allowed for GET or HEAD requests'); + } + + // if (typeof data !== 'object') { + // throw new RequestBuilderError('Data must be an object'); + // } + return this.mutateConfig({ data }); } @@ -99,11 +108,115 @@ export class HttpRequestBuilder { return this.mutateConfig({ responseType }); } + // public credentials(credentials: RequestCredentials): this { + // this.config.credentials = credentials; + // return this; + // } + + // public mode(mode: RequestMode): this { + // this.config.mode = mode; + // return this; + // } + + // public cache(cache: RequestCache): this { + // this.config.cache = cache; + // return this; + // } + + // public redirect(redirect: RequestRedirect): this { + // this.config.redirect = redirect; + // return this; + // } + + // public referrer(referrer: string): this { + // this.config.referrer = referrer; + // return this; + // } + + // public integrity(integrity: string): this { + // this.config.integrity = integrity; + // return this; + // } + + // public keepalive(keepalive: boolean): this { + // this.config.keepalive = keepalive; + // return this; + // } + + // public keepalive(value: boolean): this { + // this.config = { ...this.config, keepalive: value }; + + // return this; + // } + + // public cache(value: RequestCache): this { + // this.config = { ...this.config, cache: value }; + + // return this; + // } + + // public credentials(value: RequestCredentials): this { + // this.config = { ...this.config, credentials: value }; + + // return this; + // } + + // public mode(value: RequestMode): this { + // this.config = { ...this.config, mode: value }; + + // return this; + // } + // + // public redirect(value: RequestRedirect): this { + // this.config = { ...this.config, redirect: value }; + + // return this; + // } + + // public referrer(value: string): this { + // this.config = { ...this.config, referrer: value }; + + // return this; + // } + + // withCredentials(value: boolean): this { + // this.config.withCredentials = value; + // return this; + // } + + // json(): this { + // this.header("Content-Type", "application/json"); + // return this; + // } + + // form(): this { + // this.header("Content-Type", "application/x-www-form-urlencoded"); + // return this; + // } + + // multipart(): this { + // this.header("Content-Type", "multipart/form-data"); + // return this; + // } + + // acceptJson(): this { + // this.header("Accept", "application/json"); + // return this; + // } + public execute(): Promise> { if (!this.config.url) { throw new RequestBuilderError('URL must be set before executing'); } + if (!this.config.method) { + throw new RequestBuilderError('Method must be set before executing'); + } + + if (!this.executor) { + throw new RequestBuilderError('Executor must be set before executing'); + } + return this.executor(this.config) as Promise>; } } diff --git a/services/http-client/src/core/index.ts b/services/http-client/src/core/index.ts new file mode 100644 index 00000000..64f0f3e7 --- /dev/null +++ b/services/http-client/src/core/index.ts @@ -0,0 +1,2 @@ +export { HttpClient } from './HttpClient'; +export { HttpRequestBuilder } from './HttpRequestBuilder'; \ No newline at end of file diff --git a/services/http-client/src/index.ts b/services/http-client/src/index.ts index 56066655..575d3c01 100644 --- a/services/http-client/src/index.ts +++ b/services/http-client/src/index.ts @@ -1 +1,5 @@ -console.log('Привет, мир!'); \ No newline at end of file +export { HttpClient, HttpRequestBuilder } from './core'; +export { FetchAdapter, XhrAdapter } from './adapters'; +export * from './constants'; +export * from './errors'; +export * from './types'; \ No newline at end of file diff --git a/services/http-client/src/types/HttpClientOptions.ts b/services/http-client/src/types/HttpClientOptions.ts index 67e5b92b..e84eb9a7 100644 --- a/services/http-client/src/types/HttpClientOptions.ts +++ b/services/http-client/src/types/HttpClientOptions.ts @@ -3,7 +3,7 @@ import { THttpHeaders } from './HttpHeaders'; export interface IHttpClientOptions { adapter: IHttpClientAdapter; - baseURL?: string; + baseUrl?: string; headers?: THttpHeaders; timeout?: number; -} \ No newline at end of file +} diff --git a/services/http-client/src/types/HttpRequestConfig.ts b/services/http-client/src/types/HttpRequestConfig.ts index e8e3277d..5f956a8b 100644 --- a/services/http-client/src/types/HttpRequestConfig.ts +++ b/services/http-client/src/types/HttpRequestConfig.ts @@ -6,7 +6,7 @@ import { THttpResponseType } from './HttpResponseType'; export interface IHttpRequestConfig { readonly url: string; readonly method: THttpMethod; - readonly baseURL?: string; + readonly baseUrl?: string; readonly headers?: THttpHeaders; readonly params?: THttpParams; readonly data?: TData; diff --git a/services/http-client/src/types/HttpResponse.ts b/services/http-client/src/types/HttpResponse.ts index e272b827..d33edd20 100644 --- a/services/http-client/src/types/HttpResponse.ts +++ b/services/http-client/src/types/HttpResponse.ts @@ -3,9 +3,9 @@ import { THttpHeaders } from './HttpHeaders'; import { IHttpRequestConfig } from './HttpRequestConfig'; export interface IHttpResponse { - data: T; + data?: T; status: THttpStatusCode; statusText: string; headers: THttpHeaders; config: IHttpRequestConfig; -} \ No newline at end of file +} From ac6acb2138ad06251b2f71857ed69a1fa7566a10 Mon Sep 17 00:00:00 2001 From: sadcitizen Date: Sat, 20 Jun 2026 21:56:19 +0500 Subject: [PATCH 11/60] feat(http): add tests --- .../test/core/HttpClient.DELETE.tests.ts | 44 ++++++ .../test/core/HttpClient.GET.tests.ts | 120 +++++++++++++++ .../test/core/HttpClient.HEAD.tests.ts | 44 ++++++ .../test/core/HttpClient.OPTIONS.tests.ts | 47 ++++++ .../test/core/HttpClient.PATCH.tests.ts | 46 ++++++ .../test/core/HttpClient.POST.tests.ts | 85 +++++++++++ .../test/core/HttpClient.PUT.tests.ts | 46 ++++++ .../core/HttpClient.abort-timeout.tests.ts | 139 ++++++++++++++++++ .../core/HttpClient.defaultConfig.tests.ts | 117 +++++++++++++++ 9 files changed, 688 insertions(+) create mode 100644 services/http-client/test/core/HttpClient.DELETE.tests.ts create mode 100644 services/http-client/test/core/HttpClient.GET.tests.ts create mode 100644 services/http-client/test/core/HttpClient.HEAD.tests.ts create mode 100644 services/http-client/test/core/HttpClient.OPTIONS.tests.ts create mode 100644 services/http-client/test/core/HttpClient.PATCH.tests.ts create mode 100644 services/http-client/test/core/HttpClient.POST.tests.ts create mode 100644 services/http-client/test/core/HttpClient.PUT.tests.ts create mode 100644 services/http-client/test/core/HttpClient.abort-timeout.tests.ts create mode 100644 services/http-client/test/core/HttpClient.defaultConfig.tests.ts 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..e56e7f17 --- /dev/null +++ b/services/http-client/test/core/HttpClient.DELETE.tests.ts @@ -0,0 +1,44 @@ +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_STATUS_CODES } from '../../src/constants'; +import { IHttpClientAdapter } from '../../src/types'; + +const BASE_URL = 'https://api.test.com'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +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 () => { + server.use( + http.delete(`${BASE_URL}/items/1`, () => { + return new HttpResponse(null, { status: 204 }); + }) + ); + + const client = createClient(); + const response = await client.delete('/items/1').responseType('text').execute(); + + expect(response.status).toBe(HTTP_STATUS_CODES.NO_CONTENT); + expect(response.data).toBeUndefined(); + }); +}); \ No newline at end of file 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..86d8268e --- /dev/null +++ b/services/http-client/test/core/HttpClient.GET.tests.ts @@ -0,0 +1,120 @@ +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_STATUS_CODES } from '../../src/constants'; +import { HttpError } from '../../src/errors'; +import { IHttpClientAdapter } from '../../src/types'; + +const BASE_URL = 'https://api.test.com'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +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 — GET', ({ 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 () => { + server.use( + http.get(`${BASE_URL}/users/1`, () => { + return HttpResponse.json({ id: 1, name: 'John' }); + }) + ); + + const client = createClient(); + const response = await client.get('/users/1').execute<{ id: number; name: string }>(); + + 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 () => { + server.use( + 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') + }); + }) + ); + + const client = createClient(); + const response = await client + .get('/users') + .params({ page: '2', role: ['admin', 'user'] }) + .execute<{ page: string | null; role: string[] }>(); + + expect(response.data.page).toBe('2'); + expect(response.data.role).toEqual(['admin', 'user']); + }); + + test('sends custom headers', async () => { + server.use( + http.get(`${BASE_URL}/headers`, ({ request }) => { + return HttpResponse.json({ + auth: request.headers.get('authorization'), + custom: request.headers.get('x-custom') + }); + }) + ); + + const client = createClient(); + const response = await client + .get('/headers') + .header('Authorization', 'Bearer token') + .header('X-Custom', 'value') + .execute<{ auth: string | null; custom: string | null }>(); + + expect(response.data.auth).toBe('Bearer token'); + expect(response.data.custom).toBe('value'); + }); + + test('returns text when responseType is text', async () => { + server.use( + http.get(`${BASE_URL}/text`, () => { + return new HttpResponse('hello world', { headers: { 'Content-Type': 'text/plain' } }); + }) + ); + + const client = createClient(); + const response = await client.get('/text').responseType('text').execute(); + + expect(response.data).toBe('hello world'); + }); + + test('throws HttpError with statusCode and data on 404', async () => { + server.use( + http.get(`${BASE_URL}/not-found`, () => { + return HttpResponse.json({ error: 'Not found' }, { status: 404 }); + }) + ); + + const client = createClient(); + + try { + await client.get('/not-found').execute(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(HttpError); + expect((error as HttpError).statusCode).toBe(HTTP_STATUS_CODES.NOT_FOUND); + expect((error as HttpError).data).toEqual({ error: 'Not found' }); + } + }); +}); \ No newline at end of file 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..e7dbc741 --- /dev/null +++ b/services/http-client/test/core/HttpClient.HEAD.tests.ts @@ -0,0 +1,44 @@ +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_STATUS_CODES } from '../../src/constants'; +import { IHttpClientAdapter } from '../../src/types'; + +const BASE_URL = 'https://api.test.com'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +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 () => { + server.use( + http.head(`${BASE_URL}/items`, () => { + return new HttpResponse(null, { status: 200, headers: { 'X-Total': '42' } }); + }) + ); + + const client = createClient(); + const response = await client.head('/items').responseType('text').execute(); + + expect(response.status).toBe(HTTP_STATUS_CODES.OK); + expect(response.headers['x-total']).toBe('42'); + }); +}); \ No newline at end of file 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..471e45e5 --- /dev/null +++ b/services/http-client/test/core/HttpClient.OPTIONS.tests.ts @@ -0,0 +1,47 @@ +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_STATUS_CODES } from '../../src/constants'; +import { IHttpClientAdapter } from '../../src/types'; + +const BASE_URL = 'https://api.test.com'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +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 () => { + server.use( + http.options(`${BASE_URL}/items`, () => { + return new HttpResponse(null, { + status: 204, + headers: { Allow: 'GET, POST, HEAD, OPTIONS' } + }); + }) + ); + + const client = createClient(); + const response = await client.options('/items').responseType('text').execute(); + + expect(response.status).toBe(HTTP_STATUS_CODES.NO_CONTENT); + expect(response.headers['allow']).toBe('GET, POST, HEAD, OPTIONS'); + }); +}); \ No newline at end of file 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..611e6583 --- /dev/null +++ b/services/http-client/test/core/HttpClient.PATCH.tests.ts @@ -0,0 +1,46 @@ +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 { IHttpClientAdapter } from '../../src/types'; + +const BASE_URL = 'https://api.test.com'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +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 () => { + server.use( + http.patch(`${BASE_URL}/items/1`, async ({ request }) => { + const body = await request.json(); + return HttpResponse.json({ patched: true, ...body }); + }) + ); + + const client = createClient(); + const response = await client + .patch('/items/1') + .body({ name: 'Patched' }) + .execute<{ patched: boolean; name: string }>(); + + expect(response.data).toEqual({ patched: true, name: 'Patched' }); + }); +}); \ No newline at end of file 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..83f958c9 --- /dev/null +++ b/services/http-client/test/core/HttpClient.POST.tests.ts @@ -0,0 +1,85 @@ +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 { IHttpClientAdapter } from '../../src/types'; + +const BASE_URL = 'https://api.test.com'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +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 () => { + server.use( + http.post(`${BASE_URL}/items`, async ({ request }) => { + const contentType = request.headers.get('content-type'); + const body = await request.json(); + return HttpResponse.json({ received: body, contentType }); + }) + ); + + const client = createClient(); + const response = await client + .post('/items') + .body({ name: 'New item' }) + .execute<{ received: { name: string }; contentType: string | null }>(); + + expect(response.data.received).toEqual({ name: 'New item' }); + expect(response.data.contentType).toBe('application/json'); + }); + + test('sends string body without overriding Content-Type', async () => { + server.use( + 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 }); + }) + ); + + const client = createClient(); + const response = await client + .post('/raw') + .body('plain text body') + .header('Content-Type', 'text/plain') + .execute<{ received: string; contentType: string | null }>(); + + expect(response.data.received).toBe('plain text body'); + expect(response.data.contentType).toBe('text/plain'); + }); + + test('echoes body back', async () => { + server.use( + http.post(`${BASE_URL}/echo`, async ({ request }) => { + const body = await request.json(); + return HttpResponse.json({ created: true, ...body }); + }) + ); + + const client = createClient(); + const response = await client + .post('/echo') + .body({ name: 'Jane' }) + .execute<{ created: boolean; name: string }>(); + + expect(response.data).toEqual({ created: true, name: 'Jane' }); + }); +}); \ No newline at end of file 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..66f57247 --- /dev/null +++ b/services/http-client/test/core/HttpClient.PUT.tests.ts @@ -0,0 +1,46 @@ +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 { IHttpClientAdapter } from '../../src/types'; + +const BASE_URL = 'https://api.test.com'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +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 () => { + server.use( + http.put(`${BASE_URL}/items/1`, async ({ request }) => { + const body = await request.json(); + return HttpResponse.json({ updated: true, ...body }); + }) + ); + + const client = createClient(); + const response = await client + .put('/items/1') + .body({ name: 'Updated' }) + .execute<{ updated: boolean; name: string }>(); + + expect(response.data).toEqual({ updated: true, name: 'Updated' }); + }); +}); \ No newline at end of file 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..36844350 --- /dev/null +++ b/services/http-client/test/core/HttpClient.abort-timeout.tests.ts @@ -0,0 +1,139 @@ +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'; + +const BASE_URL = 'https://api.test.com'; + +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(); + + try { + await client.get('/').signal(controller.signal).execute(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(AbortError); + } + }); + + 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 Event('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 Event('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'); + } finally { + vi.unstubAllGlobals(); + } + }); +}); \ No newline at end of file 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..69f681ab --- /dev/null +++ b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts @@ -0,0 +1,117 @@ +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_STATUS_CODES } from '../../src/constants'; +import { HttpError, NetworkError } from '../../src/errors'; +import { IHttpClientAdapter } from '../../src/types'; + +const BASE_URL = 'https://api.test.com'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +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' } + }); + } + + test('merges default headers with request headers', async () => { + server.use( + http.get(`${BASE_URL}/test`, ({ request }) => { + return HttpResponse.json({ + def: request.headers.get('x-default'), + custom: request.headers.get('x-custom') + }); + }) + ); + + const client = createClient(); + const response = await client + .get('/test') + .header('X-Custom', 'value') + .execute<{ def: string | null; custom: string | null }>(); + + expect(response.data.def).toBe('default-header'); + expect(response.data.custom).toBe('value'); + }); + + test('request headers override default headers', async () => { + server.use( + http.get(`${BASE_URL}/test`, ({ request }) => { + return HttpResponse.json({ + def: request.headers.get('x-default') + }); + }) + ); + + const client = createClient(); + const response = await client + .get('/test') + .header('X-Default', 'overridden') + .execute<{ def: string | null }>(); + + expect(response.data.def).toBe('overridden'); + }); + + test('uses default baseUrl', async () => { + server.use( + http.get(`${BASE_URL}/base-test`, () => { + return HttpResponse.json({ ok: true }); + }) + ); + + const client = createClient(); + const response = await client.get('/base-test').execute<{ ok: boolean }>(); + + expect(response.data.ok).toBe(true); + }); + + test('throws HttpError on 500', async () => { + server.use( + http.get(`${BASE_URL}/server-error`, () => { + return HttpResponse.json({ error: 'Internal error' }, { status: 500 }); + }) + ); + + const client = createClient(); + + try { + await client.get('/server-error').execute(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(HttpError); + expect((error as HttpError).statusCode).toBe(HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR); + } + }); + + test('throws NetworkError on network failure', async () => { + server.use( + http.get(`${BASE_URL}/network-error`, () => { + return HttpResponse.error(); + }) + ); + + const client = createClient(); + + try { + await client.get('/network-error').execute(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(NetworkError); + } + }); +}); \ No newline at end of file From 041ec752dc45be9873a6ffb020b46a8eeb61365e Mon Sep 17 00:00:00 2001 From: sadcitizen Date: Sat, 20 Jun 2026 22:31:52 +0500 Subject: [PATCH 12/60] feat(http): wip --- .../test/__fixtures__/constants.ts | 1 + .../http-client/test/__fixtures__/index.ts | 1 + .../HttpClient.DELETE.handlers.ts | 8 ++++ .../__handlers__/HttpClient.GET.handlers.ts | 31 +++++++++++++ .../__handlers__/HttpClient.HEAD.handlers.ts | 8 ++++ .../HttpClient.OPTIONS.handlers.ts | 11 +++++ .../__handlers__/HttpClient.PATCH.handlers.ts | 9 ++++ .../__handlers__/HttpClient.POST.handlers.ts | 21 +++++++++ .../__handlers__/HttpClient.PUT.handlers.ts | 9 ++++ .../HttpClient.defaultConfig.handlers.ts | 23 ++++++++++ .../test/core/HttpClient.DELETE.tests.ts | 14 ++---- .../test/core/HttpClient.GET.tests.ts | 45 ++----------------- .../test/core/HttpClient.HEAD.tests.ts | 12 ++--- .../test/core/HttpClient.OPTIONS.tests.ts | 15 ++----- .../test/core/HttpClient.PATCH.tests.ts | 13 ++---- .../test/core/HttpClient.POST.tests.ts | 29 ++---------- .../test/core/HttpClient.PUT.tests.ts | 13 ++---- .../core/HttpClient.abort-timeout.tests.ts | 3 +- .../core/HttpClient.defaultConfig.tests.ts | 43 ++---------------- 19 files changed, 150 insertions(+), 159 deletions(-) create mode 100644 services/http-client/test/__fixtures__/constants.ts create mode 100644 services/http-client/test/__fixtures__/index.ts create mode 100644 services/http-client/test/__handlers__/HttpClient.DELETE.handlers.ts create mode 100644 services/http-client/test/__handlers__/HttpClient.GET.handlers.ts create mode 100644 services/http-client/test/__handlers__/HttpClient.HEAD.handlers.ts create mode 100644 services/http-client/test/__handlers__/HttpClient.OPTIONS.handlers.ts create mode 100644 services/http-client/test/__handlers__/HttpClient.PATCH.handlers.ts create mode 100644 services/http-client/test/__handlers__/HttpClient.POST.handlers.ts create mode 100644 services/http-client/test/__handlers__/HttpClient.PUT.handlers.ts create mode 100644 services/http-client/test/__handlers__/HttpClient.defaultConfig.handlers.ts diff --git a/services/http-client/test/__fixtures__/constants.ts b/services/http-client/test/__fixtures__/constants.ts new file mode 100644 index 00000000..b18bbdba --- /dev/null +++ b/services/http-client/test/__fixtures__/constants.ts @@ -0,0 +1 @@ +export const BASE_URL = 'https://api.test.com'; \ No newline at end of file diff --git a/services/http-client/test/__fixtures__/index.ts b/services/http-client/test/__fixtures__/index.ts new file mode 100644 index 00000000..e94e4b16 --- /dev/null +++ b/services/http-client/test/__fixtures__/index.ts @@ -0,0 +1 @@ +export * from './constants'; \ No newline at end of file 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..4295af46 --- /dev/null +++ b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts @@ -0,0 +1,31 @@ +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') + }); + }), + + 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}/not-found`, () => { + return HttpResponse.json({ error: 'Not found' }, { status: 404 }); + }) +]; 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..5c12fb54 --- /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(); + 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..fbe1b185 --- /dev/null +++ b/services/http-client/test/__handlers__/HttpClient.POST.handlers.ts @@ -0,0 +1,21 @@ +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(); + 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}/echo`, async ({ request }) => { + const body = await request.json(); + 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..1c2fea36 --- /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(); + 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..83395af4 --- /dev/null +++ b/services/http-client/test/__handlers__/HttpClient.defaultConfig.handlers.ts @@ -0,0 +1,23 @@ +import { http, HttpResponse } from 'msw'; +import { BASE_URL } from '../__fixtures__'; + +export const handlers = [ + http.get(`${BASE_URL}/test`, ({ request }) => { + return HttpResponse.json({ + def: request.headers.get('x-default'), + custom: request.headers.get('x-custom') + }); + }), + + 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(); + }) +]; \ No newline at end of file diff --git a/services/http-client/test/core/HttpClient.DELETE.tests.ts b/services/http-client/test/core/HttpClient.DELETE.tests.ts index e56e7f17..43e0a6cc 100644 --- a/services/http-client/test/core/HttpClient.DELETE.tests.ts +++ b/services/http-client/test/core/HttpClient.DELETE.tests.ts @@ -1,16 +1,16 @@ -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_STATUS_CODES } from '../../src/constants'; import { IHttpClientAdapter } from '../../src/types'; - -const BASE_URL = 'https://api.test.com'; +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()); @@ -29,16 +29,10 @@ describe.each(adapters)('HttpClient.$name — DELETE', ({ create }) => { } test('returns 204 with undefined data', async () => { - server.use( - http.delete(`${BASE_URL}/items/1`, () => { - return new HttpResponse(null, { status: 204 }); - }) - ); - const client = createClient(); const response = await client.delete('/items/1').responseType('text').execute(); expect(response.status).toBe(HTTP_STATUS_CODES.NO_CONTENT); expect(response.data).toBeUndefined(); }); -}); \ No newline at end of file +}); diff --git a/services/http-client/test/core/HttpClient.GET.tests.ts b/services/http-client/test/core/HttpClient.GET.tests.ts index 86d8268e..6af10336 100644 --- a/services/http-client/test/core/HttpClient.GET.tests.ts +++ b/services/http-client/test/core/HttpClient.GET.tests.ts @@ -1,4 +1,3 @@ -import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { HttpClient } from '../../src/core/HttpClient'; import { FetchAdapter } from '../../src/adapters/FetchAdapter'; @@ -6,12 +5,13 @@ import { XhrAdapter } from '../../src/adapters/XhrAdapter'; import { HTTP_STATUS_CODES } from '../../src/constants'; import { HttpError } from '../../src/errors'; import { IHttpClientAdapter } from '../../src/types'; - -const BASE_URL = 'https://api.test.com'; +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()); @@ -30,12 +30,6 @@ describe.each(adapters)('HttpClient.$name — GET', ({ create }) => { } test('returns parsed JSON with status and statusText', async () => { - server.use( - http.get(`${BASE_URL}/users/1`, () => { - return HttpResponse.json({ id: 1, name: 'John' }); - }) - ); - const client = createClient(); const response = await client.get('/users/1').execute<{ id: number; name: string }>(); @@ -45,16 +39,6 @@ describe.each(adapters)('HttpClient.$name — GET', ({ create }) => { }); test('sends query params including arrays', async () => { - server.use( - 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') - }); - }) - ); - const client = createClient(); const response = await client .get('/users') @@ -66,15 +50,6 @@ describe.each(adapters)('HttpClient.$name — GET', ({ create }) => { }); test('sends custom headers', async () => { - server.use( - http.get(`${BASE_URL}/headers`, ({ request }) => { - return HttpResponse.json({ - auth: request.headers.get('authorization'), - custom: request.headers.get('x-custom') - }); - }) - ); - const client = createClient(); const response = await client .get('/headers') @@ -87,12 +62,6 @@ describe.each(adapters)('HttpClient.$name — GET', ({ create }) => { }); test('returns text when responseType is text', async () => { - server.use( - http.get(`${BASE_URL}/text`, () => { - return new HttpResponse('hello world', { headers: { 'Content-Type': 'text/plain' } }); - }) - ); - const client = createClient(); const response = await client.get('/text').responseType('text').execute(); @@ -100,12 +69,6 @@ describe.each(adapters)('HttpClient.$name — GET', ({ create }) => { }); test('throws HttpError with statusCode and data on 404', async () => { - server.use( - http.get(`${BASE_URL}/not-found`, () => { - return HttpResponse.json({ error: 'Not found' }, { status: 404 }); - }) - ); - const client = createClient(); try { @@ -117,4 +80,4 @@ describe.each(adapters)('HttpClient.$name — GET', ({ create }) => { expect((error as HttpError).data).toEqual({ error: 'Not found' }); } }); -}); \ No newline at end of file +}); diff --git a/services/http-client/test/core/HttpClient.HEAD.tests.ts b/services/http-client/test/core/HttpClient.HEAD.tests.ts index e7dbc741..06cc54f6 100644 --- a/services/http-client/test/core/HttpClient.HEAD.tests.ts +++ b/services/http-client/test/core/HttpClient.HEAD.tests.ts @@ -1,16 +1,16 @@ -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_STATUS_CODES } from '../../src/constants'; import { IHttpClientAdapter } from '../../src/types'; - -const BASE_URL = 'https://api.test.com'; +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()); @@ -29,12 +29,6 @@ describe.each(adapters)('HttpClient.$name — HEAD', ({ create }) => { } test('returns headers without body', async () => { - server.use( - http.head(`${BASE_URL}/items`, () => { - return new HttpResponse(null, { status: 200, headers: { 'X-Total': '42' } }); - }) - ); - const client = createClient(); const response = await client.head('/items').responseType('text').execute(); diff --git a/services/http-client/test/core/HttpClient.OPTIONS.tests.ts b/services/http-client/test/core/HttpClient.OPTIONS.tests.ts index 471e45e5..97e2ebd1 100644 --- a/services/http-client/test/core/HttpClient.OPTIONS.tests.ts +++ b/services/http-client/test/core/HttpClient.OPTIONS.tests.ts @@ -1,16 +1,16 @@ -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_STATUS_CODES } from '../../src/constants'; import { IHttpClientAdapter } from '../../src/types'; - -const BASE_URL = 'https://api.test.com'; +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()); @@ -29,15 +29,6 @@ describe.each(adapters)('HttpClient.$name — OPTIONS', ({ create }) => { } test('returns Allow header', async () => { - server.use( - http.options(`${BASE_URL}/items`, () => { - return new HttpResponse(null, { - status: 204, - headers: { Allow: 'GET, POST, HEAD, OPTIONS' } - }); - }) - ); - const client = createClient(); const response = await client.options('/items').responseType('text').execute(); diff --git a/services/http-client/test/core/HttpClient.PATCH.tests.ts b/services/http-client/test/core/HttpClient.PATCH.tests.ts index 611e6583..8e58b30c 100644 --- a/services/http-client/test/core/HttpClient.PATCH.tests.ts +++ b/services/http-client/test/core/HttpClient.PATCH.tests.ts @@ -1,15 +1,15 @@ -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 { IHttpClientAdapter } from '../../src/types'; - -const BASE_URL = 'https://api.test.com'; +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()); @@ -28,13 +28,6 @@ describe.each(adapters)('HttpClient.$name — PATCH', ({ create }) => { } test('sends JSON body and receives patched data', async () => { - server.use( - http.patch(`${BASE_URL}/items/1`, async ({ request }) => { - const body = await request.json(); - return HttpResponse.json({ patched: true, ...body }); - }) - ); - const client = createClient(); const response = await client .patch('/items/1') diff --git a/services/http-client/test/core/HttpClient.POST.tests.ts b/services/http-client/test/core/HttpClient.POST.tests.ts index 83f958c9..28aa9830 100644 --- a/services/http-client/test/core/HttpClient.POST.tests.ts +++ b/services/http-client/test/core/HttpClient.POST.tests.ts @@ -1,15 +1,15 @@ -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 { IHttpClientAdapter } from '../../src/types'; - -const BASE_URL = 'https://api.test.com'; +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()); @@ -28,14 +28,6 @@ describe.each(adapters)('HttpClient.$name — POST', ({ create }) => { } test('sends JSON body with auto Content-Type', async () => { - server.use( - http.post(`${BASE_URL}/items`, async ({ request }) => { - const contentType = request.headers.get('content-type'); - const body = await request.json(); - return HttpResponse.json({ received: body, contentType }); - }) - ); - const client = createClient(); const response = await client .post('/items') @@ -47,14 +39,6 @@ describe.each(adapters)('HttpClient.$name — POST', ({ create }) => { }); test('sends string body without overriding Content-Type', async () => { - server.use( - 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 }); - }) - ); - const client = createClient(); const response = await client .post('/raw') @@ -67,13 +51,6 @@ describe.each(adapters)('HttpClient.$name — POST', ({ create }) => { }); test('echoes body back', async () => { - server.use( - http.post(`${BASE_URL}/echo`, async ({ request }) => { - const body = await request.json(); - return HttpResponse.json({ created: true, ...body }); - }) - ); - const client = createClient(); const response = await client .post('/echo') diff --git a/services/http-client/test/core/HttpClient.PUT.tests.ts b/services/http-client/test/core/HttpClient.PUT.tests.ts index 66f57247..f492735b 100644 --- a/services/http-client/test/core/HttpClient.PUT.tests.ts +++ b/services/http-client/test/core/HttpClient.PUT.tests.ts @@ -1,15 +1,15 @@ -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 { IHttpClientAdapter } from '../../src/types'; - -const BASE_URL = 'https://api.test.com'; +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()); @@ -28,13 +28,6 @@ describe.each(adapters)('HttpClient.$name — PUT', ({ create }) => { } test('sends JSON body and receives updated data', async () => { - server.use( - http.put(`${BASE_URL}/items/1`, async ({ request }) => { - const body = await request.json(); - return HttpResponse.json({ updated: true, ...body }); - }) - ); - const client = createClient(); const response = await client .put('/items/1') diff --git a/services/http-client/test/core/HttpClient.abort-timeout.tests.ts b/services/http-client/test/core/HttpClient.abort-timeout.tests.ts index 36844350..6b2e79be 100644 --- a/services/http-client/test/core/HttpClient.abort-timeout.tests.ts +++ b/services/http-client/test/core/HttpClient.abort-timeout.tests.ts @@ -4,8 +4,7 @@ import { FetchAdapter } from '../../src/adapters/FetchAdapter'; import { XhrAdapter } from '../../src/adapters/XhrAdapter'; import { AbortError, TimeoutError } from '../../src/errors'; import { IHttpClientAdapter } from '../../src/types'; - -const BASE_URL = 'https://api.test.com'; +import { BASE_URL } from '../__fixtures__'; const server = setupServer(); diff --git a/services/http-client/test/core/HttpClient.defaultConfig.tests.ts b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts index 69f681ab..454b00f7 100644 --- a/services/http-client/test/core/HttpClient.defaultConfig.tests.ts +++ b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts @@ -1,4 +1,3 @@ -import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { HttpClient } from '../../src/core/HttpClient'; import { FetchAdapter } from '../../src/adapters/FetchAdapter'; @@ -6,12 +5,13 @@ import { XhrAdapter } from '../../src/adapters/XhrAdapter'; import { HTTP_STATUS_CODES } from '../../src/constants'; import { HttpError, NetworkError } from '../../src/errors'; import { IHttpClientAdapter } from '../../src/types'; - -const BASE_URL = 'https://api.test.com'; +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()); @@ -30,15 +30,6 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { } test('merges default headers with request headers', async () => { - server.use( - http.get(`${BASE_URL}/test`, ({ request }) => { - return HttpResponse.json({ - def: request.headers.get('x-default'), - custom: request.headers.get('x-custom') - }); - }) - ); - const client = createClient(); const response = await client .get('/test') @@ -50,30 +41,16 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { }); test('request headers override default headers', async () => { - server.use( - http.get(`${BASE_URL}/test`, ({ request }) => { - return HttpResponse.json({ - def: request.headers.get('x-default') - }); - }) - ); - const client = createClient(); const response = await client .get('/test') .header('X-Default', 'overridden') - .execute<{ def: string | null }>(); + .execute<{ def: string | null; custom: string | null }>(); expect(response.data.def).toBe('overridden'); }); test('uses default baseUrl', async () => { - server.use( - http.get(`${BASE_URL}/base-test`, () => { - return HttpResponse.json({ ok: true }); - }) - ); - const client = createClient(); const response = await client.get('/base-test').execute<{ ok: boolean }>(); @@ -81,12 +58,6 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { }); test('throws HttpError on 500', async () => { - server.use( - http.get(`${BASE_URL}/server-error`, () => { - return HttpResponse.json({ error: 'Internal error' }, { status: 500 }); - }) - ); - const client = createClient(); try { @@ -99,12 +70,6 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { }); test('throws NetworkError on network failure', async () => { - server.use( - http.get(`${BASE_URL}/network-error`, () => { - return HttpResponse.error(); - }) - ); - const client = createClient(); try { From 8a81c589d5d953ecf82b3420d231dca78ad6578c Mon Sep 17 00:00:00 2001 From: Eugene Abrosimov Date: Sat, 11 Jul 2026 22:46:57 +0500 Subject: [PATCH 13/60] feat(http): add request builder error codes --- services/http-client/src/constants/index.ts | 1 + .../src/constants/requestBuilderErrorCodes.ts | 16 ++++++++++++++++ .../src/errors/RequestBuilderError.ts | 8 +++++++- .../src/types/RequestBuilderErrorCode.ts | 3 +++ 4 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 services/http-client/src/constants/requestBuilderErrorCodes.ts create mode 100644 services/http-client/src/types/RequestBuilderErrorCode.ts diff --git a/services/http-client/src/constants/index.ts b/services/http-client/src/constants/index.ts index a7417248..4c9d3e40 100644 --- a/services/http-client/src/constants/index.ts +++ b/services/http-client/src/constants/index.ts @@ -1,3 +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..72d09a42 --- /dev/null +++ b/services/http-client/src/constants/requestBuilderErrorCodes.ts @@ -0,0 +1,16 @@ +export const REQUEST_BUILDER_ERROR_CODES = { + INVALID_BASE_URL: 'INVALID_BASE_URL', + INVALID_BEARER_TOKEN: 'INVALID_BEARER_TOKEN', + INVALID_BODY: 'INVALID_BODY', + INVALID_CONFIG: 'INVALID_CONFIG', + INVALID_EXECUTOR: 'INVALID_EXECUTOR', + INVALID_HEADER: 'INVALID_HEADER', + INVALID_HEADERS: 'INVALID_HEADERS', + 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' +} as const; diff --git a/services/http-client/src/errors/RequestBuilderError.ts b/services/http-client/src/errors/RequestBuilderError.ts index ef1601ab..240253e4 100644 --- a/services/http-client/src/errors/RequestBuilderError.ts +++ b/services/http-client/src/errors/RequestBuilderError.ts @@ -1,7 +1,13 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { TRequestBuilderErrorCode } from '../types'; + export class RequestBuilderError extends Error { - constructor(message: string) { + public readonly code: TRequestBuilderErrorCode; + + constructor(message: string, code: TRequestBuilderErrorCode = REQUEST_BUILDER_ERROR_CODES.INVALID_CONFIG) { super(message); this.name = this.constructor.name; + this.code = code; Object.setPrototypeOf(this, new.target.prototype); } 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]; From 84425b3d3bbb28d75e69218ff47c6b6aac355af2 Mon Sep 17 00:00:00 2001 From: Eugene Abrosimov Date: Sat, 11 Jul 2026 22:47:46 +0500 Subject: [PATCH 14/60] feat(http): add executor type --- services/http-client/src/types/HttpRequestExecutor.ts | 4 ++++ services/http-client/src/types/index.ts | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 services/http-client/src/types/HttpRequestExecutor.ts 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/index.ts b/services/http-client/src/types/index.ts index cd6c9575..b8f29726 100644 --- a/services/http-client/src/types/index.ts +++ b/services/http-client/src/types/index.ts @@ -3,7 +3,9 @@ export * from './HttpStatusCode'; export * from './HttpClientAdapter'; export * from './HttpClientOptions'; export * from './HttpRequestConfig'; +export * from './HttpRequestExecutor'; export * from './HttpResponse'; export * from './HttpHeaders'; export * from './HttpParams'; -export * from './HttpResponseType'; \ No newline at end of file +export * from './HttpResponseType'; +export * from './RequestBuilderErrorCode'; From 368b9ac03aefbf98d9a48abbed68177b3755e695 Mon Sep 17 00:00:00 2001 From: Eugene Abrosimov Date: Sun, 12 Jul 2026 19:24:43 +0500 Subject: [PATCH 15/60] feat(http): wip --- .../src/asserts/assertBodyAllowed.ts | 12 + .../src/asserts/assertNonBlankString.ts | 12 + .../src/asserts/assertValidBaseUrl.ts | 6 + .../src/asserts/assertValidHeader.ts | 22 ++ .../src/asserts/assertValidHeaders.ts | 15 ++ .../src/asserts/assertValidMethod.ts | 9 + .../src/asserts/assertValidParam.ts | 15 ++ .../src/asserts/assertValidParams.ts | 15 ++ .../src/asserts/assertValidResponseType.ts | 12 + .../src/asserts/assertValidSignal.ts | 14 ++ .../src/asserts/assertValidTimeout.ts | 11 + .../http-client/src/asserts/assertValidUrl.ts | 6 + .../asserts/headerValueLineBreakPattern.ts | 1 + services/http-client/src/asserts/index.ts | 13 + services/http-client/src/asserts/isRecord.ts | 9 + services/http-client/src/core/HttpClient.ts | 14 +- .../src/core/HttpRequestBuilder.ts | 210 ++++++++++------ .../test/core/HttpRequestBuilder.tests-d.ts | 34 +++ .../test/core/HttpRequestBuilder.tests.ts | 226 ++++++++++++++++++ 19 files changed, 587 insertions(+), 69 deletions(-) create mode 100644 services/http-client/src/asserts/assertBodyAllowed.ts create mode 100644 services/http-client/src/asserts/assertNonBlankString.ts create mode 100644 services/http-client/src/asserts/assertValidBaseUrl.ts create mode 100644 services/http-client/src/asserts/assertValidHeader.ts create mode 100644 services/http-client/src/asserts/assertValidHeaders.ts create mode 100644 services/http-client/src/asserts/assertValidMethod.ts create mode 100644 services/http-client/src/asserts/assertValidParam.ts create mode 100644 services/http-client/src/asserts/assertValidParams.ts create mode 100644 services/http-client/src/asserts/assertValidResponseType.ts create mode 100644 services/http-client/src/asserts/assertValidSignal.ts create mode 100644 services/http-client/src/asserts/assertValidTimeout.ts create mode 100644 services/http-client/src/asserts/assertValidUrl.ts create mode 100644 services/http-client/src/asserts/headerValueLineBreakPattern.ts create mode 100644 services/http-client/src/asserts/index.ts create mode 100644 services/http-client/src/asserts/isRecord.ts create mode 100644 services/http-client/test/core/HttpRequestBuilder.tests-d.ts create mode 100644 services/http-client/test/core/HttpRequestBuilder.tests.ts diff --git a/services/http-client/src/asserts/assertBodyAllowed.ts b/services/http-client/src/asserts/assertBodyAllowed.ts new file mode 100644 index 00000000..144f4567 --- /dev/null +++ b/services/http-client/src/asserts/assertBodyAllowed.ts @@ -0,0 +1,12 @@ +import { HTTP_METHODS, REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; +import { THttpMethod } from '../types'; + +export function assertBodyAllowed(method: THttpMethod): 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 + ); + } +} 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/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/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/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..4e488554 --- /dev/null +++ b/services/http-client/src/asserts/assertValidParam.ts @@ -0,0 +1,15 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; +import { THttpParamValue } from '../types'; +import { assertNonBlankString } from './assertNonBlankString'; + +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 (typeof value !== 'string' && !(Array.isArray(value) && value.every(item => typeof item === 'string'))) { + throw new RequestBuilderError( + 'Param value must be a string or an array of strings', + 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/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/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..4bad65fc --- /dev/null +++ b/services/http-client/src/asserts/index.ts @@ -0,0 +1,13 @@ +export * from './assertBodyAllowed'; +export * from './assertNonBlankString'; +export * from './assertValidBaseUrl'; +export * from './assertValidHeader'; +export * from './assertValidHeaders'; +export * from './assertValidMethod'; +export * from './assertValidParam'; +export * from './assertValidParams'; +export * from './assertValidResponseType'; +export * from './assertValidSignal'; +export * from './assertValidTimeout'; +export * from './assertValidUrl'; +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/core/HttpClient.ts b/services/http-client/src/core/HttpClient.ts index 91a588dd..907221cf 100644 --- a/services/http-client/src/core/HttpClient.ts +++ b/services/http-client/src/core/HttpClient.ts @@ -1,5 +1,12 @@ import { HTTP_METHODS } from '../constants'; -import { THttpMethod, IHttpRequestConfig, IHttpResponse, IHttpClientOptions, IHttpClientAdapter } from '../types'; +import { + THttpMethod, + IHttpRequestConfig, + IHttpResponse, + IHttpClientOptions, + IHttpClientAdapter, + THttpRequestExecutor +} from '../types'; import { HttpRequestBuilder } from './HttpRequestBuilder'; export class HttpClient { @@ -17,7 +24,10 @@ export class HttpClient { } private request(method: THttpMethod, url: string): HttpRequestBuilder { - return new HttpRequestBuilder(config => this.execute(config), method, url); + const executor: THttpRequestExecutor = (config: IHttpRequestConfig): Promise> => + this.execute(config); + + return new HttpRequestBuilder(executor, method, url); } public get(url: string): HttpRequestBuilder { diff --git a/services/http-client/src/core/HttpRequestBuilder.ts b/services/http-client/src/core/HttpRequestBuilder.ts index 29c84840..bdf7198a 100644 --- a/services/http-client/src/core/HttpRequestBuilder.ts +++ b/services/http-client/src/core/HttpRequestBuilder.ts @@ -1,4 +1,4 @@ -import { HTTP_RESPONSE_TYPES } from '../constants'; +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; import { RequestBuilderError } from '../errors'; import { THttpMethod, @@ -7,105 +7,193 @@ import { THttpHeaders, THttpParams, THttpResponseType, - THttpParamValue + THttpParamValue, + THttpRequestExecutor } from '../types'; +import { + HEADER_VALUE_LINE_BREAK_PATTERN, + assertBodyAllowed, + assertNonBlankString, + assertValidBaseUrl, + assertValidHeader, + assertValidHeaders, + assertValidMethod, + assertValidParam, + assertValidParams, + assertValidResponseType, + assertValidSignal, + assertValidTimeout, + assertValidUrl +} from '../asserts'; + +type TBuilderConfigPatch = Partial>; + +function cloneParams(params: THttpParams): THttpParams { + const result: THttpParams = {}; + + for (const [key, value] of Object.entries(params)) { + result[key] = Array.isArray(value) ? [...value] : value; + } -type TExecutor = (config: IHttpRequestConfig) => Promise>; + return result; +} + +function cloneConfig(config: IHttpRequestConfig): IHttpRequestConfig { + return { + ...config, + ...(config.headers === undefined ? {} : { headers: { ...config.headers } }), + ...(config.params === undefined ? {} : { params: cloneParams(config.params) }) + }; +} +/** + * Builds request configs immutably. Every configuration method returns a new builder instance. + */ export class HttpRequestBuilder { - private executor: TExecutor; + private readonly executor: THttpRequestExecutor; private config: IHttpRequestConfig; - constructor(executor: TExecutor, method: THttpMethod, url: string) { + 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 mutateConfig(partial: Partial): this { - this.config = { ...this.config, ...partial }; + private withConfig(partial: TBuilderConfigPatch): HttpRequestBuilder { + const builder = new HttpRequestBuilder(this.executor, this.config.method, this.config.url); + builder.config = cloneConfig({ ...this.config, ...partial }); - return this; + return builder; } - public baseURL(value: string): this { - if (!value) { - throw new RequestBuilderError('Base URL must be a non-empty string'); + public build(): Readonly { + assertValidMethod(this.config.method); + assertValidUrl(this.config.url); + + if (this.config.baseUrl !== undefined) { + assertValidBaseUrl(this.config.baseUrl); } - return this.mutateConfig({ baseUrl: value }); - } + if (this.config.headers !== undefined) { + assertValidHeaders(this.config.headers); + } - public header(key: string, value: string): this { - if (!key) { - throw new RequestBuilderError('Header key must be a non-empty string'); + if (this.config.params !== undefined) { + assertValidParams(this.config.params); } - return this.mutateConfig({ headers: { ...this.config.headers, [key]: value } }); - } + if (this.config.signal !== undefined) { + assertValidSignal(this.config.signal); + } - public headers(headers: THttpHeaders): this { - if (!headers || typeof headers !== 'object') { - throw new RequestBuilderError('Headers must be an object'); + if (this.config.timeout !== undefined) { + assertValidTimeout(this.config.timeout); } - return this.mutateConfig({ headers: { ...this.config.headers, ...headers } }); - } + if (this.config.responseType !== undefined) { + assertValidResponseType(this.config.responseType); + } - public param(key: string, value: THttpParamValue): this { - if (!key) { - throw new RequestBuilderError('Param key must be a non-empty string'); + if (Object.prototype.hasOwnProperty.call(this.config, 'data')) { + assertBodyAllowed(this.config.method); } - return this.mutateConfig({ params: { ...this.config.params, [key]: value } }); + return cloneConfig(this.config); } - public params(params: THttpParams): this { - if (!params || typeof params !== 'object') { - throw new RequestBuilderError('Params must be an object'); - } + public baseUrl(value: string): HttpRequestBuilder { + assertValidBaseUrl(value); - return this.mutateConfig({ params: { ...this.config.params, ...params } }); + return this.withConfig({ baseUrl: value }); } - public body(data: unknown): this { - if (this.config.method === 'GET' || this.config.method === 'HEAD') { - // Возможно ещё и для DELETE - throw new RequestBuilderError('Body is not allowed for GET or HEAD requests'); - } + /** @deprecated Use baseUrl instead. */ + public baseURL(value: string): HttpRequestBuilder { + return this.baseUrl(value); + } + + public header(key: string, value: string): HttpRequestBuilder { + assertValidHeader(key, value); + + return this.withConfig({ headers: { ...this.config.headers, [key]: value } }); + } + + public headers(headers: THttpHeaders): HttpRequestBuilder { + assertValidHeaders(headers); + + return this.withConfig({ headers: { ...this.config.headers, ...headers } }); + } + + public param(key: string, value: THttpParamValue): HttpRequestBuilder { + assertValidParam(key, value); + + return this.withConfig({ + params: { + ...cloneParams(this.config.params ?? {}), + [key]: Array.isArray(value) ? [...value] : value + } + }); + } + + public params(params: THttpParams): HttpRequestBuilder { + assertValidParams(params); + + return this.withConfig({ + params: { ...cloneParams(this.config.params ?? {}), ...cloneParams(params) } + }); + } + + public body(data: unknown): HttpRequestBuilder { + // Возможно ещё и для DELETE + assertBodyAllowed(this.config.method); // if (typeof data !== 'object') { // throw new RequestBuilderError('Data must be an object'); // } - return this.mutateConfig({ data }); + return this.withConfig({ data }); } - public signal(signal: AbortSignal): this { - return this.mutateConfig({ signal }); + public signal(signal: AbortSignal): HttpRequestBuilder { + assertValidSignal(signal); + + return this.withConfig({ signal }); } - public timeout(timeout: number): this { - if (!Number.isFinite(timeout) || timeout < 0) { - throw new RequestBuilderError('Timeout must be a finite non-negative number'); - } + /** A zero timeout disables the request timeout, including a timeout inherited from the client config. */ + public timeout(timeout: number): HttpRequestBuilder { + assertValidTimeout(timeout); - return this.mutateConfig({ timeout }); + return this.withConfig({ timeout }); } - public bearer(token: string): this { - if (!token) { - throw new RequestBuilderError('Bearer token must be a non-empty string'); + 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 responseType(responseType: THttpResponseType): this { - if (!Object.values(HTTP_RESPONSE_TYPES).includes(responseType)) { - throw new RequestBuilderError('Response type must be a valid HTTP response type'); - } + public responseType(responseType: THttpResponseType): HttpRequestBuilder { + assertValidResponseType(responseType); - return this.mutateConfig({ responseType }); + return this.withConfig({ responseType }); } // public credentials(credentials: RequestCredentials): this { @@ -204,19 +292,7 @@ export class HttpRequestBuilder { // return this; // } - public execute(): Promise> { - if (!this.config.url) { - throw new RequestBuilderError('URL must be set before executing'); - } - - if (!this.config.method) { - throw new RequestBuilderError('Method must be set before executing'); - } - - if (!this.executor) { - throw new RequestBuilderError('Executor must be set before executing'); - } - - return this.executor(this.config) as Promise>; + public execute(): Promise> { + return this.executor(this.build()); } } 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..a0bf8738 --- /dev/null +++ b/services/http-client/test/core/HttpRequestBuilder.tests-d.ts @@ -0,0 +1,34 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { HTTP_METHODS, HTTP_STATUS_CODES } from '../../src/constants'; +import { HttpRequestBuilder } from '../../src/core/HttpRequestBuilder'; +import { IHttpRequestConfig, IHttpResponse, THttpRequestExecutor } from '../../src/types'; + +const executor: THttpRequestExecutor = (config: IHttpRequestConfig): Promise> => + Promise.resolve({ + status: HTTP_STATUS_CODES.OK, + statusText: 'OK', + headers: {}, + config + }); + +describe('HttpRequestBuilder types', () => { + it('preserves the response type', () => { + 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<{ id: number }>()).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(); + }); +}); 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..4c83af35 --- /dev/null +++ b/services/http-client/test/core/HttpRequestBuilder.tests.ts @@ -0,0 +1,226 @@ +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 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) + .bearer('token') + .responseType(HTTP_RESPONSE_TYPES.JSON); + + 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, + responseType: HTTP_RESPONSE_TYPES.JSON + }); + }); + + test('keeps baseURL as an alias for baseUrl', () => { + const builder = new HttpRequestBuilder(createExecutor(), HTTP_METHODS.GET, '/items'); + + expect(builder.baseURL('https://example.test').build().baseUrl).toBe('https://example.test'); + }); + + 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.execute<{ id: number }>(); + + expect(response.status).toBe(HTTP_STATUS_CODES.OK); + expect(configs).toEqual([{ method: HTTP_METHODS.GET, url: '/items', params: { page: '1' } }]); + 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: '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', 1 as unknown as string), + 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: 'bearer token', + action: (builder: HttpRequestBuilder) => builder.bearer(' '), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_BEARER_TOKEN + }, + { + name: 'response type', + action: (builder: HttpRequestBuilder) => builder.responseType('invalid'), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_RESPONSE_TYPE + } + ])('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('keeps the default error code for backwards-compatible construction', () => { + expect(new RequestBuilderError('Invalid config').code).toBe(REQUEST_BUILDER_ERROR_CODES.INVALID_CONFIG); + }); +}); From 613b4ba64c7a61a5fa80f738a4caefe7e5aa948f Mon Sep 17 00:00:00 2001 From: Eugene Abrosimov Date: Sun, 12 Jul 2026 20:11:01 +0500 Subject: [PATCH 16/60] feat(http): wip --- .../http-client/src/errors/ResponseError.ts | 29 +++++++++++++++++++ services/http-client/src/errors/index.ts | 1 + 2 files changed, 30 insertions(+) create mode 100644 services/http-client/src/errors/ResponseError.ts diff --git a/services/http-client/src/errors/ResponseError.ts b/services/http-client/src/errors/ResponseError.ts new file mode 100644 index 00000000..2dc5886b --- /dev/null +++ b/services/http-client/src/errors/ResponseError.ts @@ -0,0 +1,29 @@ +import { IHttpRequestConfig, THttpHeaders, THttpStatusCode } from '../types'; + +export class ResponseError extends Error { + public readonly statusCode: THttpStatusCode; + public readonly statusText: string; + public readonly headers: THttpHeaders; + public readonly config: IHttpRequestConfig; + public readonly data: T | undefined; + + constructor( + message: string, + statusCode: THttpStatusCode, + statusText: string, + headers: THttpHeaders, + config: IHttpRequestConfig, + data?: T + ) { + super(message); + this.name = this.constructor.name; + + this.statusCode = statusCode; + this.statusText = statusText; + this.headers = headers; + this.config = config; + this.data = data; + + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/services/http-client/src/errors/index.ts b/services/http-client/src/errors/index.ts index a9a42390..94653f58 100644 --- a/services/http-client/src/errors/index.ts +++ b/services/http-client/src/errors/index.ts @@ -3,4 +3,5 @@ export { HttpError } from './HttpError'; export { NetworkError } from './NetworkError'; export { ParseError } from './ParseError'; export { RequestBuilderError } from './RequestBuilderError'; +export { ResponseError } from './ResponseError'; export { TimeoutError } from './TimeoutError'; From b90a047508ae4dabbe3802c3f40d9a794b56158e Mon Sep 17 00:00:00 2001 From: Eugene Abrosimov Date: Sat, 18 Jul 2026 20:47:13 +0500 Subject: [PATCH 17/60] feat(http): wip --- services/http-client/src/adapters/FetchAdapter.ts | 10 +++++----- services/http-client/src/adapters/XhrAdapter.ts | 15 ++++++++------- .../src/constants/httpResponseTypes.ts | 2 +- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/services/http-client/src/adapters/FetchAdapter.ts b/services/http-client/src/adapters/FetchAdapter.ts index 02ae5c9a..031b2750 100644 --- a/services/http-client/src/adapters/FetchAdapter.ts +++ b/services/http-client/src/adapters/FetchAdapter.ts @@ -1,4 +1,4 @@ -import { HTTP_METHODS } from '../constants'; +import { HTTP_METHODS, HTTP_RESPONSE_TYPES } from '../constants'; import { HttpError } from '../errors/HttpError'; import { NetworkError } from '../errors/NetworkError'; import { TimeoutError } from '../errors/TimeoutError'; @@ -45,13 +45,13 @@ async function parseResponseBody(response: Response, responseType?: THttpResp } switch (responseType) { - case 'text': + case HTTP_RESPONSE_TYPES.TEXT: return response.text() as Promise; - case 'blob': + case HTTP_RESPONSE_TYPES.BLOB: return response.blob() as Promise; - case 'arraybuffer': + case HTTP_RESPONSE_TYPES.ARRAY_BUFFER: return response.arrayBuffer() as Promise; - case 'json': + case HTTP_RESPONSE_TYPES.JSON: default: { const text = await response.text(); if (!text) { diff --git a/services/http-client/src/adapters/XhrAdapter.ts b/services/http-client/src/adapters/XhrAdapter.ts index cf5669f0..826aaadc 100644 --- a/services/http-client/src/adapters/XhrAdapter.ts +++ b/services/http-client/src/adapters/XhrAdapter.ts @@ -1,4 +1,4 @@ -import { HTTP_METHODS } from '../constants'; +import { HTTP_METHODS, HTTP_RESPONSE_TYPES } from '../constants'; import { HttpError } from '../errors/HttpError'; import { NetworkError } from '../errors/NetworkError'; import { TimeoutError } from '../errors/TimeoutError'; @@ -51,12 +51,12 @@ function getResponseBody(xhr: XMLHttpRequest, responseType?: THttpResponseType): } switch (responseType) { - case 'arraybuffer': - case 'blob': + case HTTP_RESPONSE_TYPES.ARRAY_BUFFER: + case HTTP_RESPONSE_TYPES.BLOB: return xhr.response; - case 'text': + case HTTP_RESPONSE_TYPES.TEXT: return xhr.response; - case 'json': + case HTTP_RESPONSE_TYPES.JSON: default: { const text = xhr.response as string; if (!text) { @@ -96,9 +96,10 @@ export class XhrAdapter implements IHttpClientAdapter { xhr.timeout = timeout; } - if (responseType === 'arraybuffer') { + if (responseType === HTTP_RESPONSE_TYPES.ARRAY_BUFFER) { + // XHR accepts only the lowercase DOM enum value xhr.responseType = 'arraybuffer'; - } else if (responseType === 'blob') { + } else if (responseType === HTTP_RESPONSE_TYPES.BLOB) { xhr.responseType = 'blob'; } else { xhr.responseType = 'text'; diff --git a/services/http-client/src/constants/httpResponseTypes.ts b/services/http-client/src/constants/httpResponseTypes.ts index 505d8970..ba24e0d7 100644 --- a/services/http-client/src/constants/httpResponseTypes.ts +++ b/services/http-client/src/constants/httpResponseTypes.ts @@ -5,4 +5,4 @@ export const HTTP_RESPONSE_TYPES = { JSON: 'json', STREAM: 'stream', TEXT: 'text' -}; +} as const; From 19ec934fcaa94a44cd3bc9d39806a71f3297f6dd Mon Sep 17 00:00:00 2001 From: Eugene Abrosimov Date: Sat, 18 Jul 2026 20:47:31 +0500 Subject: [PATCH 18/60] feat(http): wip --- .../test/__handlers__/HttpClient.GET.handlers.ts | 5 +++++ .../http-client/test/core/HttpClient.GET.tests.ts | 13 ++++++++++++- .../test/core/HttpRequestBuilder.tests.ts | 3 ++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts index 4295af46..23e9a848 100644 --- a/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts +++ b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts @@ -25,6 +25,11 @@ export const handlers = [ return new HttpResponse('hello world', { headers: { 'Content-Type': 'text/plain' } }); }), + 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}/not-found`, () => { return HttpResponse.json({ error: 'Not found' }, { status: 404 }); }) diff --git a/services/http-client/test/core/HttpClient.GET.tests.ts b/services/http-client/test/core/HttpClient.GET.tests.ts index 6af10336..defa9a42 100644 --- a/services/http-client/test/core/HttpClient.GET.tests.ts +++ b/services/http-client/test/core/HttpClient.GET.tests.ts @@ -2,7 +2,7 @@ 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 { HTTP_RESPONSE_TYPES, HTTP_STATUS_CODES } from '../../src/constants'; import { HttpError } from '../../src/errors'; import { IHttpClientAdapter } from '../../src/types'; import { handlers } from '../__handlers__/HttpClient.GET.handlers'; @@ -68,6 +68,17 @@ describe.each(adapters)('HttpClient.$name — GET', ({ create }) => { expect(response.data).toBe('hello world'); }); + test('returns ArrayBuffer when responseType is arrayBuffer', async () => { + const client = createClient(); + const response = await client + .get('/binary') + .responseType(HTTP_RESPONSE_TYPES.ARRAY_BUFFER) + .execute(); + + expect(response.data).toBeInstanceOf(ArrayBuffer); + expect(Array.from(new Uint8Array(response.data as ArrayBuffer))).toEqual([1, 2, 3, 4]); + }); + test('throws HttpError with statusCode and data on 404', async () => { const client = createClient(); diff --git a/services/http-client/test/core/HttpRequestBuilder.tests.ts b/services/http-client/test/core/HttpRequestBuilder.tests.ts index 4c83af35..f6a167d9 100644 --- a/services/http-client/test/core/HttpRequestBuilder.tests.ts +++ b/services/http-client/test/core/HttpRequestBuilder.tests.ts @@ -8,6 +8,7 @@ import { THttpMethod, THttpParams, THttpRequestExecutor, + THttpResponseType, TRequestBuilderErrorCode } from '../../src/types'; @@ -199,7 +200,7 @@ describe('HttpRequestBuilder', () => { }, { name: 'response type', - action: (builder: HttpRequestBuilder) => builder.responseType('invalid'), + action: (builder: HttpRequestBuilder) => builder.responseType('invalid' as THttpResponseType), code: REQUEST_BUILDER_ERROR_CODES.INVALID_RESPONSE_TYPE } ])('validates $name', ({ action, code }) => { From 65c17d1b9d3490055e3c1499f765d2f88ba6f2db Mon Sep 17 00:00:00 2001 From: Eugene Abrosimov Date: Tue, 21 Jul 2026 22:19:50 +0500 Subject: [PATCH 19/60] feat(http): wip --- .../http-client/src/adapters/XhrAdapter.ts | 2 +- services/http-client/src/core/HttpClient.ts | 94 ++++- services/http-client/src/errors/AbortError.ts | 9 +- .../http-client/src/errors/HttpClientError.ts | 9 + services/http-client/src/errors/HttpError.ts | 6 +- .../http-client/src/errors/NetworkError.ts | 9 +- services/http-client/src/errors/ParseError.ts | 9 +- .../src/errors/RequestBuilderError.ts | 7 +- .../http-client/src/errors/ResponseError.ts | 6 +- .../http-client/src/errors/TimeoutError.ts | 9 +- services/http-client/src/errors/index.ts | 1 + .../src/types/HttpClientOptions.ts | 5 + services/http-client/src/types/HttpHooks.ts | 38 +++ services/http-client/src/types/index.ts | 1 + .../__handlers__/HttpClient.hooks.handlers.ts | 23 ++ .../test/core/HttpClient.hooks.tests.ts | 321 ++++++++++++++++++ .../test/errors/HttpClientError.tests.ts | 74 ++++ .../test/types/HttpHooks.tests-d.ts | 79 +++++ 18 files changed, 657 insertions(+), 45 deletions(-) create mode 100644 services/http-client/src/errors/HttpClientError.ts create mode 100644 services/http-client/src/types/HttpHooks.ts create mode 100644 services/http-client/test/__handlers__/HttpClient.hooks.handlers.ts create mode 100644 services/http-client/test/core/HttpClient.hooks.tests.ts create mode 100644 services/http-client/test/errors/HttpClientError.tests.ts create mode 100644 services/http-client/test/types/HttpHooks.tests-d.ts diff --git a/services/http-client/src/adapters/XhrAdapter.ts b/services/http-client/src/adapters/XhrAdapter.ts index 826aaadc..fb7e19a3 100644 --- a/services/http-client/src/adapters/XhrAdapter.ts +++ b/services/http-client/src/adapters/XhrAdapter.ts @@ -97,7 +97,7 @@ export class XhrAdapter implements IHttpClientAdapter { } if (responseType === HTTP_RESPONSE_TYPES.ARRAY_BUFFER) { - // XHR accepts only the lowercase DOM enum value + // XHR принимает только DOM-значение в нижнем регистре xhr.responseType = 'arraybuffer'; } else if (responseType === HTTP_RESPONSE_TYPES.BLOB) { xhr.responseType = 'blob'; diff --git a/services/http-client/src/core/HttpClient.ts b/services/http-client/src/core/HttpClient.ts index 907221cf..6beb1499 100644 --- a/services/http-client/src/core/HttpClient.ts +++ b/services/http-client/src/core/HttpClient.ts @@ -1,20 +1,33 @@ import { HTTP_METHODS } from '../constants'; +import { HttpClientError } from '../errors'; import { THttpMethod, IHttpRequestConfig, IHttpResponse, IHttpClientOptions, IHttpClientAdapter, - THttpRequestExecutor + THttpRequestExecutor, + THttpRequestHook, + THttpRequestErrorHook, + THttpResponseHook, + THttpResponseErrorHook } from '../types'; import { HttpRequestBuilder } from './HttpRequestBuilder'; export class HttpClient { private readonly adapter: IHttpClientAdapter; private readonly defaultConfig: Pick; + private onRequestHook?: THttpRequestHook; + private onRequestErrorHook?: THttpRequestErrorHook; + private onResponseHook?: THttpResponseHook; + private onResponseErrorHook?: THttpResponseErrorHook; constructor(options: IHttpClientOptions) { this.adapter = options.adapter; + this.onRequestHook = options.onRequest; + this.onRequestErrorHook = options.onRequestError; + this.onResponseHook = options.onResponse; + this.onResponseErrorHook = options.onResponseError; this.defaultConfig = { baseUrl: options.baseUrl, @@ -23,6 +36,34 @@ export class HttpClient { }; } + /** Устанавливает хук onRequest, заменяя заданный через опции конструктора. Возвращает клиент для чейнинга. */ + public onRequest(hook: THttpRequestHook): this { + this.onRequestHook = hook; + + return this; + } + + /** Устанавливает хук onRequestError, заменяя заданный через опции конструктора. Возвращает клиент для чейнинга. */ + public onRequestError(hook: THttpRequestErrorHook): this { + this.onRequestErrorHook = hook; + + return this; + } + + /** Устанавливает хук onResponse, заменяя заданный через опции конструктора. Возвращает клиент для чейнинга. */ + public onResponse(hook: THttpResponseHook): this { + this.onResponseHook = hook; + + return this; + } + + /** Устанавливает хук onResponseError, заменяя заданный через опции конструктора. Возвращает клиент для чейнинга. */ + public onResponseError(hook: THttpResponseErrorHook): this { + this.onResponseErrorHook = hook; + + return this; + } + private request(method: THttpMethod, url: string): HttpRequestBuilder { const executor: THttpRequestExecutor = (config: IHttpRequestConfig): Promise> => this.execute(config); @@ -66,8 +107,15 @@ export class HttpClient { return this.request(HTTP_METHODS.PATCH, url); } - private execute(config: IHttpRequestConfig): Promise> { - const mergedConfig: IHttpRequestConfig = { + private async execute(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, @@ -75,6 +123,44 @@ export class HttpClient { params: { ...this.defaultConfig.params, ...config.params } }; - return this.adapter.request(mergedConfig); + if (onRequest !== undefined) { + try { + mergedConfig = await onRequest(mergedConfig); + } catch (error) { + if (onRequestError === undefined) { + throw error; + } + + const recoveredConfig = await onRequestError(error); + + if (recoveredConfig === undefined) { + throw error; + } + + 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 as HttpClientError); + + if (recoveredResponse === undefined) { + throw error; + } + + return recoveredResponse as IHttpResponse; + } } } diff --git a/services/http-client/src/errors/AbortError.ts b/services/http-client/src/errors/AbortError.ts index dd2a3ded..3ce462d3 100644 --- a/services/http-client/src/errors/AbortError.ts +++ b/services/http-client/src/errors/AbortError.ts @@ -1,8 +1,3 @@ -export class AbortError extends Error { - constructor(message: string) { - super(message); - this.name = this.constructor.name; +import { HttpClientError } from './HttpClientError'; - Object.setPrototypeOf(this, new.target.prototype); - } -} +export class AbortError extends HttpClientError {} diff --git a/services/http-client/src/errors/HttpClientError.ts b/services/http-client/src/errors/HttpClientError.ts new file mode 100644 index 00000000..93ea2bbf --- /dev/null +++ b/services/http-client/src/errors/HttpClientError.ts @@ -0,0 +1,9 @@ +/** Базовый класс всех ошибок http-клиента. Позволяет поймать любую ошибку клиента одной проверкой instanceof. */ +export class HttpClientError extends Error { + constructor(message: string) { + super(message); + this.name = this.constructor.name; + + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/services/http-client/src/errors/HttpError.ts b/services/http-client/src/errors/HttpError.ts index db5bfcc7..efb107c1 100644 --- a/services/http-client/src/errors/HttpError.ts +++ b/services/http-client/src/errors/HttpError.ts @@ -1,16 +1,14 @@ import { THttpStatusCode } from '../types'; +import { HttpClientError } from './HttpClientError'; -export class HttpError extends Error { +export class HttpError extends HttpClientError { public readonly statusCode?: THttpStatusCode; public readonly data?: unknown; constructor(message: string, statusCode?: THttpStatusCode, data?: unknown) { super(message); - this.name = this.constructor.name; this.statusCode = statusCode; this.data = data; - - Object.setPrototypeOf(this, new.target.prototype); } } diff --git a/services/http-client/src/errors/NetworkError.ts b/services/http-client/src/errors/NetworkError.ts index b3b0f52b..2ec3a48c 100644 --- a/services/http-client/src/errors/NetworkError.ts +++ b/services/http-client/src/errors/NetworkError.ts @@ -1,8 +1,3 @@ -export class NetworkError extends Error { - constructor(message: string) { - super(message); - this.name = this.constructor.name; +import { HttpClientError } from './HttpClientError'; - Object.setPrototypeOf(this, new.target.prototype); - } -} +export class NetworkError extends HttpClientError {} diff --git a/services/http-client/src/errors/ParseError.ts b/services/http-client/src/errors/ParseError.ts index 64762876..20ef5e5e 100644 --- a/services/http-client/src/errors/ParseError.ts +++ b/services/http-client/src/errors/ParseError.ts @@ -1,8 +1,3 @@ -export class ParseError extends Error { - constructor(message: string) { - super(message); - this.name = this.constructor.name; +import { HttpClientError } from './HttpClientError'; - Object.setPrototypeOf(this, new.target.prototype); - } -} +export class ParseError extends HttpClientError {} diff --git a/services/http-client/src/errors/RequestBuilderError.ts b/services/http-client/src/errors/RequestBuilderError.ts index 240253e4..43460d4e 100644 --- a/services/http-client/src/errors/RequestBuilderError.ts +++ b/services/http-client/src/errors/RequestBuilderError.ts @@ -1,14 +1,13 @@ import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; import { TRequestBuilderErrorCode } from '../types'; +import { HttpClientError } from './HttpClientError'; -export class RequestBuilderError extends Error { +export class RequestBuilderError extends HttpClientError { public readonly code: TRequestBuilderErrorCode; constructor(message: string, code: TRequestBuilderErrorCode = REQUEST_BUILDER_ERROR_CODES.INVALID_CONFIG) { super(message); - this.name = this.constructor.name; - this.code = code; - Object.setPrototypeOf(this, new.target.prototype); + this.code = code; } } diff --git a/services/http-client/src/errors/ResponseError.ts b/services/http-client/src/errors/ResponseError.ts index 2dc5886b..084cd11f 100644 --- a/services/http-client/src/errors/ResponseError.ts +++ b/services/http-client/src/errors/ResponseError.ts @@ -1,6 +1,7 @@ import { IHttpRequestConfig, THttpHeaders, THttpStatusCode } from '../types'; +import { HttpClientError } from './HttpClientError'; -export class ResponseError extends Error { +export class ResponseError extends HttpClientError { public readonly statusCode: THttpStatusCode; public readonly statusText: string; public readonly headers: THttpHeaders; @@ -16,14 +17,11 @@ export class ResponseError extends Error { data?: T ) { super(message); - this.name = this.constructor.name; this.statusCode = statusCode; this.statusText = statusText; this.headers = headers; this.config = config; this.data = data; - - Object.setPrototypeOf(this, new.target.prototype); } } diff --git a/services/http-client/src/errors/TimeoutError.ts b/services/http-client/src/errors/TimeoutError.ts index 7d6e84b0..c0387c9e 100644 --- a/services/http-client/src/errors/TimeoutError.ts +++ b/services/http-client/src/errors/TimeoutError.ts @@ -1,8 +1,3 @@ -export class TimeoutError extends Error { - constructor(message: string) { - super(message); - this.name = this.constructor.name; +import { HttpClientError } from './HttpClientError'; - Object.setPrototypeOf(this, new.target.prototype); - } -} +export class TimeoutError extends HttpClientError {} diff --git a/services/http-client/src/errors/index.ts b/services/http-client/src/errors/index.ts index 94653f58..fc64c9a4 100644 --- a/services/http-client/src/errors/index.ts +++ b/services/http-client/src/errors/index.ts @@ -1,4 +1,5 @@ export { AbortError } from './AbortError'; +export { HttpClientError } from './HttpClientError'; export { HttpError } from './HttpError'; export { NetworkError } from './NetworkError'; export { ParseError } from './ParseError'; diff --git a/services/http-client/src/types/HttpClientOptions.ts b/services/http-client/src/types/HttpClientOptions.ts index e84eb9a7..1c7efca2 100644 --- a/services/http-client/src/types/HttpClientOptions.ts +++ b/services/http-client/src/types/HttpClientOptions.ts @@ -1,9 +1,14 @@ import { IHttpClientAdapter } from './HttpClientAdapter'; import { THttpHeaders } from './HttpHeaders'; +import { THttpRequestErrorHook, THttpRequestHook, THttpResponseErrorHook, THttpResponseHook } from './HttpHooks'; export interface IHttpClientOptions { adapter: IHttpClientAdapter; baseUrl?: string; headers?: THttpHeaders; timeout?: number; + onRequest?: THttpRequestHook; + onRequestError?: THttpRequestErrorHook; + onResponse?: THttpResponseHook; + onResponseError?: THttpResponseErrorHook; } diff --git a/services/http-client/src/types/HttpHooks.ts b/services/http-client/src/types/HttpHooks.ts new file mode 100644 index 00000000..efbc5b1b --- /dev/null +++ b/services/http-client/src/types/HttpHooks.ts @@ -0,0 +1,38 @@ +/* eslint-disable @typescript-eslint/no-invalid-void-type -- + * void здесь намеренен: хуки ошибок могут ничего не возвращать, + * и только void принимает функцию без return вроде `(error) => { log(error); }`. */ +import { HttpClientError } from '../errors/HttpClientError'; +import { IHttpRequestConfig } from './HttpRequestConfig'; +import { IHttpResponse } from './HttpResponse'; + +/** + * Вызывается один раз на запрос после слияния с дефолтным конфигом клиента, + * непосредственно перед вызовом адаптера. + * Возвращённый конфиг продолжает путь по pipeline. Брошенная ошибка уходит в onRequestError. + */ +export type THttpRequestHook = (config: IHttpRequestConfig) => IHttpRequestConfig | Promise; + +/** Конфиг, с которым запрос будет продолжен, или ничего — тогда перевыбрасывается исходная ошибка. */ +export type THttpRequestErrorHookResult = IHttpRequestConfig | void; + +/** Вызывается, только если хук onRequest бросил исключение или отклонил промис. */ +export type THttpRequestErrorHook = ( + error: unknown +) => THttpRequestErrorHookResult | Promise; + +/** + * Вызывается один раз на запрос, когда адаптер успешно зарезолвился (2xx). + * Возвращённый ответ уходит вызывающему коду. Брошенная ошибка уходит в onResponseError. + */ +export type THttpResponseHook = (response: IHttpResponse) => IHttpResponse | Promise; + +/** Ответ для восстановления (onResponse на нём повторно НЕ вызывается), или ничего — тогда перевыбрасывается исходная ошибка. */ +export type THttpResponseErrorHookResult = IHttpResponse | void; + +/** + * Вызывается, когда адаптер отклонил промис (HttpError, NetworkError, TimeoutError, AbortError, ParseError) + * или когда хук onResponse бросил ошибку. + */ +export type THttpResponseErrorHook = ( + error: HttpClientError +) => THttpResponseErrorHookResult | Promise; diff --git a/services/http-client/src/types/index.ts b/services/http-client/src/types/index.ts index b8f29726..66eb779f 100644 --- a/services/http-client/src/types/index.ts +++ b/services/http-client/src/types/index.ts @@ -6,6 +6,7 @@ export * from './HttpRequestConfig'; export * from './HttpRequestExecutor'; export * from './HttpResponse'; export * from './HttpHeaders'; +export * from './HttpHooks'; export * from './HttpParams'; export * from './HttpResponseType'; export * from './RequestBuilderErrorCode'; 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/core/HttpClient.hooks.tests.ts b/services/http-client/test/core/HttpClient.hooks.tests.ts new file mode 100644 index 00000000..a9806c39 --- /dev/null +++ b/services/http-client/test/core/HttpClient.hooks.tests.ts @@ -0,0 +1,321 @@ +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 } from '../../src/constants'; +import { AbortError, HttpError, NetworkError, RequestBuilderError } 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') + .execute<{ authorization: string | null }>(); + + 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').execute<{ authorization: string | null }>(); + + expect(response.data?.authorization).toBe('Bearer async-token'); + }); + + 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').execute<{ id: number }>(); + + 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').execute<{ wrapped: { id: number; name: string } }>(); + + 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 HttpError 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(HttpError); + expect(onResponse).not.toHaveBeenCalled(); + expect(caught).toBeInstanceOf(HttpError); + expect((caught as HttpError).statusCode).toBe(HTTP_STATUS_CODES.NOT_FOUND); + expect((caught as HttpError).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 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').execute<{ recovered: boolean }>(); + + 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').execute<{ authorization: string | null }>(); + + 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').execute<{ authorization: string | null }>(); + + 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').execute<{ chained: boolean }>(); + + 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').execute<{ authorization: string | null }>(); + + expect(before.data?.authorization).toBeNull(); + + client.onRequest(config => ({ + ...config, + headers: { ...config.headers, Authorization: 'Bearer late-token' } + })); + + const after = await client.get('/echo-headers').execute<{ authorization: string | null }>(); + + 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(HttpError); + expect(caught).toBeInstanceOf(HttpError); + expect((caught as HttpError).statusCode).toBe(HTTP_STATUS_CODES.NOT_FOUND); + }); +}); 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..d42f46df --- /dev/null +++ b/services/http-client/test/errors/HttpClientError.tests.ts @@ -0,0 +1,74 @@ +import { HTTP_METHODS, HTTP_STATUS_CODES, REQUEST_BUILDER_ERROR_CODES } from '../../src/constants'; +import { + AbortError, + HttpClientError, + HttpError, + NetworkError, + ParseError, + RequestBuilderError, + ResponseError, + 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: 'HttpError', create: () => new HttpError('failed', HTTP_STATUS_CODES.BAD_REQUEST, { 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: 'ResponseError', + create: () => new ResponseError('failed', HTTP_STATUS_CODES.BAD_REQUEST, 'Bad Request', {}, config) + }, + { name: 'TimeoutError', 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('preserves HttpError fields', () => { + const error = new HttpError('failed', HTTP_STATUS_CODES.NOT_FOUND, { error: 'Not found' }); + + expect(error.message).toBe('failed'); + expect(error.statusCode).toBe(HTTP_STATUS_CODES.NOT_FOUND); + expect(error.data).toEqual({ error: 'Not found' }); + }); + + 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 ResponseError fields', () => { + const headers = { 'x-a': 'b' }; + const error = new ResponseError('failed', HTTP_STATUS_CODES.BAD_REQUEST, 'Bad Request', headers, config, { + error: 'bad' + }); + + expect(error.statusCode).toBe(HTTP_STATUS_CODES.BAD_REQUEST); + expect(error.statusText).toBe('Bad Request'); + expect(error.headers).toBe(headers); + expect(error.config).toBe(config); + expect(error.data).toEqual({ error: 'bad' }); + }); +}); 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..28ed6cb6 --- /dev/null +++ b/services/http-client/test/types/HttpHooks.tests-d.ts @@ -0,0 +1,79 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { HTTP_METHODS, HTTP_STATUS_CODES } from '../../src/constants'; +import { HttpClientError, HttpError } 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 +}; + +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('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 the HttpClientError parameter', () => { + expectTypeOf().parameter(0).toEqualTypeOf(); + + const narrowed: THttpResponseErrorHook = error => { + if (error instanceof HttpError) { + expectTypeOf(error.statusCode).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(); + }); +}); From dc5e5b8e1f295f9acb7002491b8898ebd34d5384 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Wed, 19 Aug 2026 09:19:04 +0500 Subject: [PATCH 20/60] feat(http): add prod config --- services/http-client/package.json | 6 +++--- services/http-client/src/types/HttpParams.ts | 2 +- .../__handlers__/HttpClient.PATCH.handlers.ts | 2 +- .../__handlers__/HttpClient.POST.handlers.ts | 4 ++-- .../__handlers__/HttpClient.PUT.handlers.ts | 2 +- .../test/core/HttpClient.GET.tests.ts | 8 ++++---- .../test/core/HttpClient.POST.tests.ts | 10 +++++----- .../test/core/HttpClient.abort-timeout.tests.ts | 16 +++++++++++----- .../test/core/HttpClient.defaultConfig.tests.ts | 10 +++++----- services/http-client/tsconfig.build.json | 11 +++++++++++ services/http-client/tsconfig.json | 17 +++-------------- services/http-client/vitest.config.mjs | 14 ++++++++++++++ 12 files changed, 61 insertions(+), 41 deletions(-) create mode 100644 services/http-client/tsconfig.build.json create mode 100644 services/http-client/vitest.config.mjs diff --git a/services/http-client/package.json b/services/http-client/package.json index 23ad6ea4..0a5ea534 100644 --- a/services/http-client/package.json +++ b/services/http-client/package.json @@ -17,10 +17,10 @@ "url": "git+https://github.com/Byndyusoft/ui.git" }, "scripts": { - "build": "tsc", + "build": "tsc --project tsconfig.build.json", "clean": "rimraf dist && rimraf .turbo && rimraf node_modules && rimraf package-lock.json", "lint": "eslint src --config ../../eslint.config.js", - "test": "vitest run --root ../../ services/http-client" + "test": "vitest run --config vitest.config.mjs --typecheck" }, "bugs": { "url": "https://github.com/Byndyusoft/ui/issues" @@ -31,4 +31,4 @@ "devDependencies": { "msw": "^2.4.9" } -} \ No newline at end of file +} diff --git a/services/http-client/src/types/HttpParams.ts b/services/http-client/src/types/HttpParams.ts index e1bf0bc4..5754ec55 100644 --- a/services/http-client/src/types/HttpParams.ts +++ b/services/http-client/src/types/HttpParams.ts @@ -1,3 +1,3 @@ -export type THttpParamValue = string | string[]; +export type THttpParamValue = string | Array; export type THttpParams = Record; diff --git a/services/http-client/test/__handlers__/HttpClient.PATCH.handlers.ts b/services/http-client/test/__handlers__/HttpClient.PATCH.handlers.ts index 5c12fb54..5281c252 100644 --- a/services/http-client/test/__handlers__/HttpClient.PATCH.handlers.ts +++ b/services/http-client/test/__handlers__/HttpClient.PATCH.handlers.ts @@ -3,7 +3,7 @@ import { BASE_URL } from '../__fixtures__'; export const handlers = [ http.patch(`${BASE_URL}/items/1`, async ({ request }) => { - const body = await request.json(); + 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 index fbe1b185..4cd05c2e 100644 --- a/services/http-client/test/__handlers__/HttpClient.POST.handlers.ts +++ b/services/http-client/test/__handlers__/HttpClient.POST.handlers.ts @@ -4,7 +4,7 @@ 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(); + const body = (await request.json()) as Record; return HttpResponse.json({ received: body, contentType }); }), @@ -15,7 +15,7 @@ export const handlers = [ }), http.post(`${BASE_URL}/echo`, async ({ request }) => { - const body = await request.json(); + 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 index 1c2fea36..64a40050 100644 --- a/services/http-client/test/__handlers__/HttpClient.PUT.handlers.ts +++ b/services/http-client/test/__handlers__/HttpClient.PUT.handlers.ts @@ -3,7 +3,7 @@ import { BASE_URL } from '../__fixtures__'; export const handlers = [ http.put(`${BASE_URL}/items/1`, async ({ request }) => { - const body = await request.json(); + const body = (await request.json()) as Record; return HttpResponse.json({ updated: true, ...body }); }) ]; diff --git a/services/http-client/test/core/HttpClient.GET.tests.ts b/services/http-client/test/core/HttpClient.GET.tests.ts index defa9a42..0766c04e 100644 --- a/services/http-client/test/core/HttpClient.GET.tests.ts +++ b/services/http-client/test/core/HttpClient.GET.tests.ts @@ -45,8 +45,8 @@ describe.each(adapters)('HttpClient.$name — GET', ({ create }) => { .params({ page: '2', role: ['admin', 'user'] }) .execute<{ page: string | null; role: string[] }>(); - expect(response.data.page).toBe('2'); - expect(response.data.role).toEqual(['admin', 'user']); + expect(response.data?.page).toBe('2'); + expect(response.data?.role).toEqual(['admin', 'user']); }); test('sends custom headers', async () => { @@ -57,8 +57,8 @@ describe.each(adapters)('HttpClient.$name — GET', ({ create }) => { .header('X-Custom', 'value') .execute<{ auth: string | null; custom: string | null }>(); - expect(response.data.auth).toBe('Bearer token'); - expect(response.data.custom).toBe('value'); + expect(response.data?.auth).toBe('Bearer token'); + expect(response.data?.custom).toBe('value'); }); test('returns text when responseType is text', async () => { diff --git a/services/http-client/test/core/HttpClient.POST.tests.ts b/services/http-client/test/core/HttpClient.POST.tests.ts index 28aa9830..553e482b 100644 --- a/services/http-client/test/core/HttpClient.POST.tests.ts +++ b/services/http-client/test/core/HttpClient.POST.tests.ts @@ -34,8 +34,8 @@ describe.each(adapters)('HttpClient.$name — POST', ({ create }) => { .body({ name: 'New item' }) .execute<{ received: { name: string }; contentType: string | null }>(); - expect(response.data.received).toEqual({ name: 'New item' }); - expect(response.data.contentType).toBe('application/json'); + expect(response.data?.received).toEqual({ name: 'New item' }); + expect(response.data?.contentType).toBe('application/json'); }); test('sends string body without overriding Content-Type', async () => { @@ -46,8 +46,8 @@ describe.each(adapters)('HttpClient.$name — POST', ({ create }) => { .header('Content-Type', 'text/plain') .execute<{ received: string; contentType: string | null }>(); - expect(response.data.received).toBe('plain text body'); - expect(response.data.contentType).toBe('text/plain'); + expect(response.data?.received).toBe('plain text body'); + expect(response.data?.contentType).toBe('text/plain'); }); test('echoes body back', async () => { @@ -59,4 +59,4 @@ describe.each(adapters)('HttpClient.$name — POST', ({ create }) => { expect(response.data).toEqual({ created: true, name: 'Jane' }); }); -}); \ No newline at end of file +}); diff --git a/services/http-client/test/core/HttpClient.abort-timeout.tests.ts b/services/http-client/test/core/HttpClient.abort-timeout.tests.ts index 6b2e79be..5489ecb7 100644 --- a/services/http-client/test/core/HttpClient.abort-timeout.tests.ts +++ b/services/http-client/test/core/HttpClient.abort-timeout.tests.ts @@ -62,13 +62,16 @@ describe.each(adapters)('HttpClient.$name — abort', ({ name, create }) => { setRequestHeader: vi.fn(), send: vi.fn(), abort: vi.fn(function (this: XMLHttpRequest) { - this.onabort?.(new Event('abort')); + this.onabort?.(new ProgressEvent('abort')); }), getAllResponseHeaders: vi.fn(() => ''), getResponseHeader: vi.fn(() => null) } as unknown as XMLHttpRequest; - vi.stubGlobal('XMLHttpRequest', vi.fn(() => mockXHR)); + vi.stubGlobal( + 'XMLHttpRequest', + vi.fn(() => mockXHR) + ); } const promise = client.get('/slow').signal(controller.signal).execute(); @@ -115,14 +118,17 @@ describe.each(adapters)('HttpClient.$name — timeout', ({ name, create }) => { open: vi.fn(), setRequestHeader: vi.fn(), send: vi.fn(function (this: XMLHttpRequest) { - setTimeout(() => this.ontimeout?.(new Event('timeout')), 0); + 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)); + vi.stubGlobal( + 'XMLHttpRequest', + vi.fn(() => mockXHR) + ); } try { @@ -135,4 +141,4 @@ describe.each(adapters)('HttpClient.$name — timeout', ({ name, create }) => { vi.unstubAllGlobals(); } }); -}); \ No newline at end of file +}); diff --git a/services/http-client/test/core/HttpClient.defaultConfig.tests.ts b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts index 454b00f7..3d48de02 100644 --- a/services/http-client/test/core/HttpClient.defaultConfig.tests.ts +++ b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts @@ -36,8 +36,8 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { .header('X-Custom', 'value') .execute<{ def: string | null; custom: string | null }>(); - expect(response.data.def).toBe('default-header'); - expect(response.data.custom).toBe('value'); + expect(response.data?.def).toBe('default-header'); + expect(response.data?.custom).toBe('value'); }); test('request headers override default headers', async () => { @@ -47,14 +47,14 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { .header('X-Default', 'overridden') .execute<{ def: string | null; custom: string | null }>(); - expect(response.data.def).toBe('overridden'); + expect(response.data?.def).toBe('overridden'); }); test('uses default baseUrl', async () => { const client = createClient(); const response = await client.get('/base-test').execute<{ ok: boolean }>(); - expect(response.data.ok).toBe(true); + expect(response.data?.ok).toBe(true); }); test('throws HttpError on 500', async () => { @@ -79,4 +79,4 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { expect(error).toBeInstanceOf(NetworkError); } }); -}); \ No newline at end of file +}); diff --git a/services/http-client/tsconfig.build.json b/services/http-client/tsconfig.build.json new file mode 100644 index 00000000..c32656bb --- /dev/null +++ b/services/http-client/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationDir": "dist", + "outDir": "dist", + "module": "commonjs" + }, + "include": ["../../types.d.ts", "src"], + "exclude": ["node_modules"] +} diff --git a/services/http-client/tsconfig.json b/services/http-client/tsconfig.json index 6c2dec9a..4ad8667c 100644 --- a/services/http-client/tsconfig.json +++ b/services/http-client/tsconfig.json @@ -1,16 +1,5 @@ { "extends": "../../tsconfig.json", - "compilerOptions": { - "declaration": true, - "declarationDir": "dist", - "outDir": "dist", - "module": "commonjs" - }, - "include": [ - "../../types.d.ts", - "src" - ], - "exclude": [ - "node_modules" - ] -} \ No newline at end of file + "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' + } + } +}); From 1137ed6f6900c2e8de6ef91a4c094939ef8b9b76 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Wed, 19 Aug 2026 13:51:39 +0500 Subject: [PATCH 21/60] feat(http): publish config --- services/http-client/.npmignore | 9 ++++++++- services/http-client/package.json | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/services/http-client/.npmignore b/services/http-client/.npmignore index e8310385..11dd0919 100644 --- a/services/http-client/.npmignore +++ b/services/http-client/.npmignore @@ -1 +1,8 @@ -src \ No newline at end of file +src +test +tsconfig.json +tsconfig.build.json +vitest.config.mjs +.turbo +node_modules +*.tgz diff --git a/services/http-client/package.json b/services/http-client/package.json index 0a5ea534..5866e77e 100644 --- a/services/http-client/package.json +++ b/services/http-client/package.json @@ -12,6 +12,9 @@ "license": "Apache-2.0", "main": "dist/index.js", "types": "dist/index.d.ts", + "files": [ + "dist" + ], "repository": { "type": "git", "url": "git+https://github.com/Byndyusoft/ui.git" From 640700b236b56f5b34ae694e0636783b7c6db3c5 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Wed, 19 Aug 2026 13:52:24 +0500 Subject: [PATCH 22/60] feat(http): move utilities --- .../http-client/src/adapters/FetchAdapter.ts | 26 +--- .../http-client/src/adapters/XhrAdapter.ts | 26 +--- services/http-client/src/core/HttpClient.ts | 3 +- .../src/core/HttpRequestBuilder.ts | 5 +- .../http-client/src/utilities/buildUrl.ts | 115 ++++++++++++++++++ services/http-client/src/utilities/index.ts | 2 + .../http-client/src/utilities/mergeHeaders.ts | 32 +++++ 7 files changed, 162 insertions(+), 47 deletions(-) create mode 100644 services/http-client/src/utilities/buildUrl.ts create mode 100644 services/http-client/src/utilities/index.ts create mode 100644 services/http-client/src/utilities/mergeHeaders.ts diff --git a/services/http-client/src/adapters/FetchAdapter.ts b/services/http-client/src/adapters/FetchAdapter.ts index 031b2750..87cb996c 100644 --- a/services/http-client/src/adapters/FetchAdapter.ts +++ b/services/http-client/src/adapters/FetchAdapter.ts @@ -9,27 +9,9 @@ import { IHttpResponse, THttpStatusCode, THttpHeaders, - THttpParams, THttpResponseType } from '../types'; - -function buildUrl(baseURL: string | undefined, url: string, params?: THttpParams): string { - let fullUrl = baseURL ? `${baseURL}${url}` : url; - - if (params && Object.keys(params).length > 0) { - const searchParams = new URLSearchParams(); - for (const [key, value] of Object.entries(params)) { - const values = Array.isArray(value) ? value : [value]; - for (const v of values) { - searchParams.append(key, v); - } - } - const separator = fullUrl.includes('?') ? '&' : '?'; - fullUrl += separator + searchParams.toString(); - } - - return fullUrl; -} +import { buildUrl, hasHeader, mergeHeaders } from '../utilities'; function extractResponseHeaders(headers: Headers): THttpHeaders { const result: THttpHeaders = {}; @@ -76,9 +58,9 @@ export class FetchAdapter implements IHttpClientAdapter { baseUrl: baseURL } = config; - const fullUrl = buildUrl(baseURL ?? undefined, url, params); + const fullUrl = buildUrl(baseURL, url, params); - const requestHeaders: THttpHeaders = { ...headers }; + const requestHeaders = mergeHeaders(headers); let body: BodyInit | undefined; if (data !== undefined && method !== HTTP_METHODS.GET && method !== HTTP_METHODS.HEAD) { @@ -86,7 +68,7 @@ export class FetchAdapter implements IHttpClientAdapter { body = data as BodyInit; } else { body = JSON.stringify(data); - if (!requestHeaders['Content-Type']) { + if (!hasHeader(requestHeaders, 'Content-Type')) { requestHeaders['Content-Type'] = 'application/json'; } } diff --git a/services/http-client/src/adapters/XhrAdapter.ts b/services/http-client/src/adapters/XhrAdapter.ts index fb7e19a3..e2358f73 100644 --- a/services/http-client/src/adapters/XhrAdapter.ts +++ b/services/http-client/src/adapters/XhrAdapter.ts @@ -9,27 +9,9 @@ import { IHttpResponse, THttpStatusCode, THttpHeaders, - THttpParams, THttpResponseType } from '../types'; - -function buildUrl(baseURL: string | undefined, url: string, params?: THttpParams): string { - let fullUrl = baseURL ? `${baseURL}${url}` : url; - - if (params && Object.keys(params).length > 0) { - const searchParams = new URLSearchParams(); - for (const [key, value] of Object.entries(params)) { - const values = Array.isArray(value) ? value : [value]; - for (const v of values) { - searchParams.append(key, v); - } - } - const separator = fullUrl.includes('?') ? '&' : '?'; - fullUrl += separator + searchParams.toString(); - } - - return fullUrl; -} +import { buildUrl, hasHeader, mergeHeaders } from '../utilities'; function parseResponseHeaders(rawHeaders: string): THttpHeaders { const result: THttpHeaders = {}; @@ -81,7 +63,7 @@ export class XhrAdapter implements IHttpClientAdapter { baseUrl: baseURL } = config; - const fullUrl = buildUrl(baseURL ?? undefined, url, params); + const fullUrl = buildUrl(baseURL, url, params); return new Promise>((resolve, reject) => { if (userSignal?.aborted) { @@ -105,10 +87,10 @@ export class XhrAdapter implements IHttpClientAdapter { xhr.responseType = 'text'; } - const requestHeaders: THttpHeaders = { ...headers }; + const requestHeaders = mergeHeaders(headers); if (data !== undefined && method !== HTTP_METHODS.GET && method !== HTTP_METHODS.HEAD) { if (typeof data !== 'string' && !(data instanceof ArrayBuffer) && !(data instanceof Blob)) { - if (!requestHeaders['Content-Type']) { + if (!hasHeader(requestHeaders, 'Content-Type')) { requestHeaders['Content-Type'] = 'application/json'; } } diff --git a/services/http-client/src/core/HttpClient.ts b/services/http-client/src/core/HttpClient.ts index 6beb1499..ca99e6b9 100644 --- a/services/http-client/src/core/HttpClient.ts +++ b/services/http-client/src/core/HttpClient.ts @@ -13,6 +13,7 @@ import { THttpResponseErrorHook } from '../types'; import { HttpRequestBuilder } from './HttpRequestBuilder'; +import { mergeHeaders } from '../utilities'; export class HttpClient { private readonly adapter: IHttpClientAdapter; @@ -119,7 +120,7 @@ export class HttpClient { ...config, baseUrl: config.baseUrl ?? this.defaultConfig.baseUrl, timeout: config.timeout ?? this.defaultConfig.timeout, - headers: { ...this.defaultConfig.headers, ...config.headers }, + headers: mergeHeaders(this.defaultConfig.headers, config.headers), params: { ...this.defaultConfig.params, ...config.params } }; diff --git a/services/http-client/src/core/HttpRequestBuilder.ts b/services/http-client/src/core/HttpRequestBuilder.ts index bdf7198a..b5f38ef4 100644 --- a/services/http-client/src/core/HttpRequestBuilder.ts +++ b/services/http-client/src/core/HttpRequestBuilder.ts @@ -25,6 +25,7 @@ import { assertValidTimeout, assertValidUrl } from '../asserts'; +import { mergeHeaders } from '../utilities'; type TBuilderConfigPatch = Partial>; @@ -121,13 +122,13 @@ export class HttpRequestBuilder { public header(key: string, value: string): HttpRequestBuilder { assertValidHeader(key, value); - return this.withConfig({ headers: { ...this.config.headers, [key]: value } }); + return this.withConfig({ headers: mergeHeaders(this.config.headers, { [key]: value }) }); } public headers(headers: THttpHeaders): HttpRequestBuilder { assertValidHeaders(headers); - return this.withConfig({ headers: { ...this.config.headers, ...headers } }); + return this.withConfig({ headers: mergeHeaders(this.config.headers, headers) }); } public param(key: string, value: THttpParamValue): HttpRequestBuilder { diff --git a/services/http-client/src/utilities/buildUrl.ts b/services/http-client/src/utilities/buildUrl.ts new file mode 100644 index 00000000..5b29040c --- /dev/null +++ b/services/http-client/src/utilities/buildUrl.ts @@ -0,0 +1,115 @@ +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)) { + const values = Array.isArray(value) ? value : [value]; + + for (const item of values) { + searchParams.append(key, 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 and appends params before its 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/index.ts b/services/http-client/src/utilities/index.ts new file mode 100644 index 00000000..f8ce42b2 --- /dev/null +++ b/services/http-client/src/utilities/index.ts @@ -0,0 +1,2 @@ +export { buildUrl } from './buildUrl'; +export { hasHeader, mergeHeaders } from './mergeHeaders'; diff --git a/services/http-client/src/utilities/mergeHeaders.ts b/services/http-client/src/utilities/mergeHeaders.ts new file mode 100644 index 00000000..7f6026de --- /dev/null +++ b/services/http-client/src/utilities/mergeHeaders.ts @@ -0,0 +1,32 @@ +import { THttpHeaders } from '../types'; + +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; +} + +export function hasHeader(headers: THttpHeaders, name: string): boolean { + const normalizedName = name.toLowerCase(); + + return Object.keys(headers).some(key => key.toLowerCase() === normalizedName); +} From 14c13ce2e7ef0230ad0f1553e562868f49ce3a62 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Wed, 19 Aug 2026 13:52:38 +0500 Subject: [PATCH 23/60] feat(http): move utilities --- .../__handlers__/HttpClient.GET.handlers.ts | 7 ++++- .../HttpClient.defaultConfig.handlers.ts | 5 ++-- .../test/core/HttpClient.GET.tests.ts | 29 +++++++++++++++++++ .../test/core/HttpClient.POST.tests.ts | 14 +++++++++ .../core/HttpClient.defaultConfig.tests.ts | 12 +++++++- 5 files changed, 63 insertions(+), 4 deletions(-) diff --git a/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts index 23e9a848..714e8c51 100644 --- a/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts +++ b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts @@ -10,10 +10,15 @@ export const handlers = [ const url = new URL(request.url); return HttpResponse.json({ page: url.searchParams.get('page'), - role: url.searchParams.getAll('role') + role: url.searchParams.getAll('role'), + source: url.searchParams.get('source') }); }), + 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'), diff --git a/services/http-client/test/__handlers__/HttpClient.defaultConfig.handlers.ts b/services/http-client/test/__handlers__/HttpClient.defaultConfig.handlers.ts index 83395af4..94427aa0 100644 --- a/services/http-client/test/__handlers__/HttpClient.defaultConfig.handlers.ts +++ b/services/http-client/test/__handlers__/HttpClient.defaultConfig.handlers.ts @@ -5,7 +5,8 @@ export const handlers = [ http.get(`${BASE_URL}/test`, ({ request }) => { return HttpResponse.json({ def: request.headers.get('x-default'), - custom: request.headers.get('x-custom') + custom: request.headers.get('x-custom'), + authorization: request.headers.get('authorization') }); }), @@ -20,4 +21,4 @@ export const handlers = [ http.get(`${BASE_URL}/network-error`, () => { return HttpResponse.error(); }) -]; \ No newline at end of file +]; diff --git a/services/http-client/test/core/HttpClient.GET.tests.ts b/services/http-client/test/core/HttpClient.GET.tests.ts index 0766c04e..3a5850f5 100644 --- a/services/http-client/test/core/HttpClient.GET.tests.ts +++ b/services/http-client/test/core/HttpClient.GET.tests.ts @@ -49,6 +49,24 @@ describe.each(adapters)('HttpClient.$name — GET', ({ create }) => { expect(response.data?.role).toEqual(['admin', 'user']); }); + 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') + .execute<{ page: string | null; source: string | null }>(); + + 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').execute<{ scoped: boolean }>(); + + expect(response.data?.scoped).toBe(true); + }); + test('sends custom headers', async () => { const client = createClient(); const response = await client @@ -61,6 +79,17 @@ describe.each(adapters)('HttpClient.$name — GET', ({ create }) => { 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') + .execute<{ auth: string | null }>(); + + expect(response.data?.auth).toBe('Bearer second'); + }); + test('returns text when responseType is text', async () => { const client = createClient(); const response = await client.get('/text').responseType('text').execute(); diff --git a/services/http-client/test/core/HttpClient.POST.tests.ts b/services/http-client/test/core/HttpClient.POST.tests.ts index 553e482b..11280c6e 100644 --- a/services/http-client/test/core/HttpClient.POST.tests.ts +++ b/services/http-client/test/core/HttpClient.POST.tests.ts @@ -50,6 +50,20 @@ describe.each(adapters)('HttpClient.$name — POST', ({ create }) => { expect(response.data?.contentType).toBe('text/plain'); }); + 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' }) + .execute<{ contentType: string | null }>(); + + expect(response.data?.contentType).toBe('application/vnd.api+json'); + }); + test('echoes body back', async () => { const client = createClient(); const response = await client diff --git a/services/http-client/test/core/HttpClient.defaultConfig.tests.ts b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts index 3d48de02..73bb3c68 100644 --- a/services/http-client/test/core/HttpClient.defaultConfig.tests.ts +++ b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts @@ -25,7 +25,7 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { return new HttpClient({ adapter: create(), baseUrl: BASE_URL, - headers: { 'X-Default': 'default-header' } + headers: { 'X-Default': 'default-header', Authorization: 'Bearer default' } }); } @@ -50,6 +50,16 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { 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') + .execute<{ authorization: string | null }>(); + + expect(response.data?.authorization).toBe('Bearer request'); + }); + test('uses default baseUrl', async () => { const client = createClient(); const response = await client.get('/base-test').execute<{ ok: boolean }>(); From f260935594b08550836790f0822226d7c7b4b3df Mon Sep 17 00:00:00 2001 From: abrosimov Date: Wed, 19 Aug 2026 16:43:40 +0500 Subject: [PATCH 24/60] feat(http): update errors --- .../http-client/src/adapters/FetchAdapter.ts | 17 ++++++-- .../http-client/src/adapters/XhrAdapter.ts | 21 ++++++++-- services/http-client/src/core/HttpClient.ts | 3 +- .../src/core/HttpRequestBuilder.ts | 5 --- .../http-client/src/errors/HttpClientError.ts | 4 +- services/http-client/src/errors/HttpError.ts | 14 ------- ...{ResponseError.ts => HttpResponseError.ts} | 14 ++++--- services/http-client/src/errors/ParseError.ts | 1 + services/http-client/src/errors/index.ts | 3 +- services/http-client/src/types/HttpHooks.ts | 6 +-- .../__handlers__/HttpClient.GET.handlers.ts | 9 ++++- .../test/core/HttpClient.GET.tests.ts | 27 ++++++++++--- .../core/HttpClient.defaultConfig.tests.ts | 8 ++-- .../test/core/HttpClient.hooks.tests.ts | 18 ++++----- .../test/core/HttpRequestBuilder.tests.ts | 6 --- .../test/errors/HttpClientError.tests.ts | 39 +++++++++---------- .../test/types/HttpHooks.tests-d.ts | 10 ++--- 17 files changed, 112 insertions(+), 93 deletions(-) delete mode 100644 services/http-client/src/errors/HttpError.ts rename services/http-client/src/errors/{ResponseError.ts => HttpResponseError.ts} (63%) diff --git a/services/http-client/src/adapters/FetchAdapter.ts b/services/http-client/src/adapters/FetchAdapter.ts index 87cb996c..ec6e52e3 100644 --- a/services/http-client/src/adapters/FetchAdapter.ts +++ b/services/http-client/src/adapters/FetchAdapter.ts @@ -1,6 +1,8 @@ import { HTTP_METHODS, HTTP_RESPONSE_TYPES } from '../constants'; -import { HttpError } from '../errors/HttpError'; +import { HttpClientError } from '../errors/HttpClientError'; +import { HttpResponseError } from '../errors/HttpResponseError'; import { NetworkError } from '../errors/NetworkError'; +import { ParseError } from '../errors/ParseError'; import { TimeoutError } from '../errors/TimeoutError'; import { AbortError } from '../errors/AbortError'; import { @@ -39,7 +41,11 @@ async function parseResponseBody(response: Response, responseType?: THttpResp if (!text) { return undefined as T; } - return JSON.parse(text) as T; + try { + return JSON.parse(text) as T; + } catch (error) { + throw new ParseError('Failed to parse response body as JSON', { cause: error }); + } } } } @@ -113,9 +119,12 @@ export class FetchAdapter implements IHttpClientAdapter { errorData = undefined; } - throw new HttpError( + throw new HttpResponseError( `Request failed with status code ${response.status}`, response.status as THttpStatusCode, + response.statusText, + extractResponseHeaders(response.headers), + config, errorData ); } @@ -130,7 +139,7 @@ export class FetchAdapter implements IHttpClientAdapter { config }; } catch (error) { - if (error instanceof HttpError) { + if (error instanceof HttpClientError) { throw error; } diff --git a/services/http-client/src/adapters/XhrAdapter.ts b/services/http-client/src/adapters/XhrAdapter.ts index e2358f73..be902893 100644 --- a/services/http-client/src/adapters/XhrAdapter.ts +++ b/services/http-client/src/adapters/XhrAdapter.ts @@ -1,6 +1,8 @@ import { HTTP_METHODS, HTTP_RESPONSE_TYPES } from '../constants'; -import { HttpError } from '../errors/HttpError'; +import { HttpClientError } from '../errors/HttpClientError'; +import { HttpResponseError } from '../errors/HttpResponseError'; import { NetworkError } from '../errors/NetworkError'; +import { ParseError } from '../errors/ParseError'; import { TimeoutError } from '../errors/TimeoutError'; import { AbortError } from '../errors/AbortError'; import { @@ -44,7 +46,11 @@ function getResponseBody(xhr: XMLHttpRequest, responseType?: THttpResponseType): if (!text) { return undefined; } - return JSON.parse(text); + try { + return JSON.parse(text); + } catch (error) { + throw new ParseError('Failed to parse response body as JSON', { cause: error }); + } } } } @@ -133,7 +139,11 @@ export class XhrAdapter implements IHttpClientAdapter { config }); } catch (error) { - reject(new NetworkError((error as Error).message)); + reject( + error instanceof HttpClientError + ? error + : new NetworkError((error as Error).message, { cause: error }) + ); } } else { let errorData: unknown; @@ -153,9 +163,12 @@ export class XhrAdapter implements IHttpClientAdapter { } reject( - new HttpError( + new HttpResponseError( `Request failed with status code ${xhr.status}`, xhr.status as THttpStatusCode, + xhr.statusText, + parseResponseHeaders(xhr.getAllResponseHeaders()), + config, errorData ) ); diff --git a/services/http-client/src/core/HttpClient.ts b/services/http-client/src/core/HttpClient.ts index ca99e6b9..5bc2c7e1 100644 --- a/services/http-client/src/core/HttpClient.ts +++ b/services/http-client/src/core/HttpClient.ts @@ -1,5 +1,4 @@ import { HTTP_METHODS } from '../constants'; -import { HttpClientError } from '../errors'; import { THttpMethod, IHttpRequestConfig, @@ -155,7 +154,7 @@ export class HttpClient { throw error; } - const recoveredResponse = await onResponseError(error as HttpClientError); + const recoveredResponse = await onResponseError(error); if (recoveredResponse === undefined) { throw error; diff --git a/services/http-client/src/core/HttpRequestBuilder.ts b/services/http-client/src/core/HttpRequestBuilder.ts index b5f38ef4..89b5d03f 100644 --- a/services/http-client/src/core/HttpRequestBuilder.ts +++ b/services/http-client/src/core/HttpRequestBuilder.ts @@ -114,11 +114,6 @@ export class HttpRequestBuilder { return this.withConfig({ baseUrl: value }); } - /** @deprecated Use baseUrl instead. */ - public baseURL(value: string): HttpRequestBuilder { - return this.baseUrl(value); - } - public header(key: string, value: string): HttpRequestBuilder { assertValidHeader(key, value); diff --git a/services/http-client/src/errors/HttpClientError.ts b/services/http-client/src/errors/HttpClientError.ts index 93ea2bbf..c6636754 100644 --- a/services/http-client/src/errors/HttpClientError.ts +++ b/services/http-client/src/errors/HttpClientError.ts @@ -1,7 +1,7 @@ /** Базовый класс всех ошибок http-клиента. Позволяет поймать любую ошибку клиента одной проверкой instanceof. */ export class HttpClientError extends Error { - constructor(message: string) { - super(message); + constructor(message: string, options?: ErrorOptions) { + super(message, options); this.name = this.constructor.name; Object.setPrototypeOf(this, new.target.prototype); diff --git a/services/http-client/src/errors/HttpError.ts b/services/http-client/src/errors/HttpError.ts deleted file mode 100644 index efb107c1..00000000 --- a/services/http-client/src/errors/HttpError.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { THttpStatusCode } from '../types'; -import { HttpClientError } from './HttpClientError'; - -export class HttpError extends HttpClientError { - public readonly statusCode?: THttpStatusCode; - public readonly data?: unknown; - - constructor(message: string, statusCode?: THttpStatusCode, data?: unknown) { - super(message); - - this.statusCode = statusCode; - this.data = data; - } -} diff --git a/services/http-client/src/errors/ResponseError.ts b/services/http-client/src/errors/HttpResponseError.ts similarity index 63% rename from services/http-client/src/errors/ResponseError.ts rename to services/http-client/src/errors/HttpResponseError.ts index 084cd11f..ef9fa3c8 100644 --- a/services/http-client/src/errors/ResponseError.ts +++ b/services/http-client/src/errors/HttpResponseError.ts @@ -1,8 +1,9 @@ import { IHttpRequestConfig, THttpHeaders, THttpStatusCode } from '../types'; import { HttpClientError } from './HttpClientError'; -export class ResponseError extends HttpClientError { - public readonly statusCode: THttpStatusCode; +/** 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; @@ -10,15 +11,16 @@ export class ResponseError extends HttpClientError { constructor( message: string, - statusCode: THttpStatusCode, + status: THttpStatusCode, statusText: string, headers: THttpHeaders, config: IHttpRequestConfig, - data?: T + data?: T, + options?: ErrorOptions ) { - super(message); + super(message, options); - this.statusCode = statusCode; + this.status = status; this.statusText = statusText; this.headers = headers; this.config = config; diff --git a/services/http-client/src/errors/ParseError.ts b/services/http-client/src/errors/ParseError.ts index 20ef5e5e..529cadcd 100644 --- a/services/http-client/src/errors/ParseError.ts +++ b/services/http-client/src/errors/ParseError.ts @@ -1,3 +1,4 @@ import { HttpClientError } from './HttpClientError'; +/** Indicates that a successful response could not be parsed in the requested format. */ export class ParseError extends HttpClientError {} diff --git a/services/http-client/src/errors/index.ts b/services/http-client/src/errors/index.ts index fc64c9a4..0d6131ee 100644 --- a/services/http-client/src/errors/index.ts +++ b/services/http-client/src/errors/index.ts @@ -1,8 +1,7 @@ export { AbortError } from './AbortError'; export { HttpClientError } from './HttpClientError'; -export { HttpError } from './HttpError'; +export { HttpResponseError } from './HttpResponseError'; export { NetworkError } from './NetworkError'; export { ParseError } from './ParseError'; export { RequestBuilderError } from './RequestBuilderError'; -export { ResponseError } from './ResponseError'; export { TimeoutError } from './TimeoutError'; diff --git a/services/http-client/src/types/HttpHooks.ts b/services/http-client/src/types/HttpHooks.ts index efbc5b1b..110c630c 100644 --- a/services/http-client/src/types/HttpHooks.ts +++ b/services/http-client/src/types/HttpHooks.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/no-invalid-void-type -- * void здесь намеренен: хуки ошибок могут ничего не возвращать, * и только void принимает функцию без return вроде `(error) => { log(error); }`. */ -import { HttpClientError } from '../errors/HttpClientError'; import { IHttpRequestConfig } from './HttpRequestConfig'; import { IHttpResponse } from './HttpResponse'; @@ -30,9 +29,8 @@ export type THttpResponseHook = (response: IHttpResponse) => IHttpResponse | Pro export type THttpResponseErrorHookResult = IHttpResponse | void; /** - * Вызывается, когда адаптер отклонил промис (HttpError, NetworkError, TimeoutError, AbortError, ParseError) - * или когда хук onResponse бросил ошибку. + * Вызывается, когда адаптер отклонил промис или когда хук onResponse бросил ошибку. */ export type THttpResponseErrorHook = ( - error: HttpClientError + error: unknown ) => THttpResponseErrorHookResult | Promise; diff --git a/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts index 714e8c51..26695085 100644 --- a/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts +++ b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts @@ -36,6 +36,13 @@ export const handlers = [ }), http.get(`${BASE_URL}/not-found`, () => { - return HttpResponse.json({ error: 'Not found' }, { status: 404 }); + return HttpResponse.json( + { error: 'Not found' }, + { status: 404, statusText: 'Not Found', headers: { 'X-Request-Id': 'request-1' } } + ); + }), + + http.get(`${BASE_URL}/invalid-json`, () => { + return new HttpResponse('{ invalid json', { headers: { 'Content-Type': 'application/json' } }); }) ]; diff --git a/services/http-client/test/core/HttpClient.GET.tests.ts b/services/http-client/test/core/HttpClient.GET.tests.ts index 3a5850f5..59016c17 100644 --- a/services/http-client/test/core/HttpClient.GET.tests.ts +++ b/services/http-client/test/core/HttpClient.GET.tests.ts @@ -3,7 +3,7 @@ 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 { HttpError } from '../../src/errors'; +import { HttpResponseError, ParseError } from '../../src/errors'; import { IHttpClientAdapter } from '../../src/types'; import { handlers } from '../__handlers__/HttpClient.GET.handlers'; import { BASE_URL } from '../__fixtures__'; @@ -108,16 +108,33 @@ describe.each(adapters)('HttpClient.$name — GET', ({ create }) => { expect(Array.from(new Uint8Array(response.data as ArrayBuffer))).toEqual([1, 2, 3, 4]); }); - test('throws HttpError with statusCode and data on 404', async () => { + 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(HttpError); - expect((error as HttpError).statusCode).toBe(HTTP_STATUS_CODES.NOT_FOUND); - expect((error as HttpError).data).toEqual({ error: 'Not found' }); + 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('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); + expect((error as ParseError).cause).toBeInstanceOf(SyntaxError); } }); }); diff --git a/services/http-client/test/core/HttpClient.defaultConfig.tests.ts b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts index 73bb3c68..94305bb5 100644 --- a/services/http-client/test/core/HttpClient.defaultConfig.tests.ts +++ b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts @@ -3,7 +3,7 @@ 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 { HttpError, NetworkError } from '../../src/errors'; +import { HttpResponseError, NetworkError } from '../../src/errors'; import { IHttpClientAdapter } from '../../src/types'; import { handlers } from '../__handlers__/HttpClient.defaultConfig.handlers'; import { BASE_URL } from '../__fixtures__'; @@ -67,15 +67,15 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { expect(response.data?.ok).toBe(true); }); - test('throws HttpError on 500', async () => { + 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(HttpError); - expect((error as HttpError).statusCode).toBe(HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR); + expect(error).toBeInstanceOf(HttpResponseError); + expect((error as HttpResponseError).status).toBe(HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR); } }); diff --git a/services/http-client/test/core/HttpClient.hooks.tests.ts b/services/http-client/test/core/HttpClient.hooks.tests.ts index a9806c39..305b95d4 100644 --- a/services/http-client/test/core/HttpClient.hooks.tests.ts +++ b/services/http-client/test/core/HttpClient.hooks.tests.ts @@ -4,7 +4,7 @@ import { HttpClient } from '../../src/core/HttpClient'; import { FetchAdapter } from '../../src/adapters/FetchAdapter'; import { XhrAdapter } from '../../src/adapters/XhrAdapter'; import { HTTP_METHODS, HTTP_STATUS_CODES } from '../../src/constants'; -import { AbortError, HttpError, NetworkError, RequestBuilderError } from '../../src/errors'; +import { AbortError, HttpResponseError, NetworkError, RequestBuilderError } from '../../src/errors'; import { IHttpClientAdapter, IHttpClientOptions, IHttpRequestConfig, IHttpResponse } from '../../src/types'; import { handlers } from '../__handlers__/HttpClient.hooks.handlers'; import { BASE_URL } from '../__fixtures__'; @@ -148,7 +148,7 @@ describe.each(adapters)('HttpClient.$name — hooks', ({ create }) => { expect(onResponseError).toHaveBeenCalledWith(transformError); }); - test('onResponseError receives HttpError with parsed data on 404, onResponse is not called', async () => { + 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({ @@ -160,11 +160,11 @@ describe.each(adapters)('HttpClient.$name — hooks', ({ create }) => { } }); - await expect(client.get('/not-found').execute()).rejects.toBeInstanceOf(HttpError); + await expect(client.get('/not-found').execute()).rejects.toBeInstanceOf(HttpResponseError); expect(onResponse).not.toHaveBeenCalled(); - expect(caught).toBeInstanceOf(HttpError); - expect((caught as HttpError).statusCode).toBe(HTTP_STATUS_CODES.NOT_FOUND); - expect((caught as HttpError).data).toEqual({ error: 'Not found' }); + 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 () => { @@ -314,8 +314,8 @@ describe.each(adapters)('HttpClient.$name — hooks', ({ create }) => { return undefined; }); - await expect(client.get('/not-found').execute()).rejects.toBeInstanceOf(HttpError); - expect(caught).toBeInstanceOf(HttpError); - expect((caught as HttpError).statusCode).toBe(HTTP_STATUS_CODES.NOT_FOUND); + 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); }); }); diff --git a/services/http-client/test/core/HttpRequestBuilder.tests.ts b/services/http-client/test/core/HttpRequestBuilder.tests.ts index f6a167d9..febc0052 100644 --- a/services/http-client/test/core/HttpRequestBuilder.tests.ts +++ b/services/http-client/test/core/HttpRequestBuilder.tests.ts @@ -71,12 +71,6 @@ describe('HttpRequestBuilder', () => { }); }); - test('keeps baseURL as an alias for baseUrl', () => { - const builder = new HttpRequestBuilder(createExecutor(), HTTP_METHODS.GET, '/items'); - - expect(builder.baseURL('https://example.test').build().baseUrl).toBe('https://example.test'); - }); - test('creates independent branches and build snapshots', () => { const sourceHeaders: THttpHeaders = { 'X-Source': 'source' }; const sourceParams: THttpParams = { role: ['admin'] }; diff --git a/services/http-client/test/errors/HttpClientError.tests.ts b/services/http-client/test/errors/HttpClientError.tests.ts index d42f46df..0a1e0d42 100644 --- a/services/http-client/test/errors/HttpClientError.tests.ts +++ b/services/http-client/test/errors/HttpClientError.tests.ts @@ -2,11 +2,10 @@ import { HTTP_METHODS, HTTP_STATUS_CODES, REQUEST_BUILDER_ERROR_CODES } from '.. import { AbortError, HttpClientError, - HttpError, + HttpResponseError, NetworkError, ParseError, RequestBuilderError, - ResponseError, TimeoutError } from '../../src/errors'; import { IHttpRequestConfig } from '../../src/types'; @@ -15,17 +14,17 @@ const config: IHttpRequestConfig = { method: HTTP_METHODS.GET, url: '/items' }; const errors: Array<{ name: string; create: () => HttpClientError }> = [ { name: 'AbortError', create: () => new AbortError('aborted') }, - { name: 'HttpError', create: () => new HttpError('failed', HTTP_STATUS_CODES.BAD_REQUEST, { error: 'bad' }) }, + { + 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: 'ResponseError', - create: () => new ResponseError('failed', HTTP_STATUS_CODES.BAD_REQUEST, 'Bad Request', {}, config) - }, { name: 'TimeoutError', create: () => new TimeoutError('timed out') } ]; @@ -44,11 +43,17 @@ describe('HttpClientError', () => { expect(error).toBeInstanceOf(error.constructor as new (...args: never[]) => unknown); }); - it('preserves HttpError fields', () => { - const error = new HttpError('failed', HTTP_STATUS_CODES.NOT_FOUND, { error: 'Not found' }); + it('preserves HttpResponseError fields', () => { + 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.statusCode).toBe(HTTP_STATUS_CODES.NOT_FOUND); + 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' }); }); @@ -59,16 +64,10 @@ describe('HttpClientError', () => { expect(new RequestBuilderError('invalid').code).toBe(REQUEST_BUILDER_ERROR_CODES.INVALID_CONFIG); }); - it('preserves ResponseError fields', () => { - const headers = { 'x-a': 'b' }; - const error = new ResponseError('failed', HTTP_STATUS_CODES.BAD_REQUEST, 'Bad Request', headers, config, { - error: 'bad' - }); + it('preserves the cause of a ParseError', () => { + const cause = new SyntaxError('Unexpected token'); + const error = new ParseError('Failed to parse response body as JSON', { cause }); - expect(error.statusCode).toBe(HTTP_STATUS_CODES.BAD_REQUEST); - expect(error.statusText).toBe('Bad Request'); - expect(error.headers).toBe(headers); - expect(error.config).toBe(config); - expect(error.data).toEqual({ error: 'bad' }); + expect(error.cause).toBe(cause); }); }); diff --git a/services/http-client/test/types/HttpHooks.tests-d.ts b/services/http-client/test/types/HttpHooks.tests-d.ts index 28ed6cb6..2bbea0ff 100644 --- a/services/http-client/test/types/HttpHooks.tests-d.ts +++ b/services/http-client/test/types/HttpHooks.tests-d.ts @@ -1,6 +1,6 @@ import { describe, expectTypeOf, it } from 'vitest'; import { HTTP_METHODS, HTTP_STATUS_CODES } from '../../src/constants'; -import { HttpClientError, HttpError } from '../../src/errors'; +import { HttpResponseError } from '../../src/errors'; import { IHttpRequestConfig, IHttpResponse, @@ -51,12 +51,12 @@ describe('THttpRequestErrorHook', () => { }); describe('THttpResponseErrorHook', () => { - it('accepts the HttpClientError parameter', () => { - expectTypeOf().parameter(0).toEqualTypeOf(); + it('accepts errors from adapters and response hooks', () => { + expectTypeOf().parameter(0).toEqualTypeOf(); const narrowed: THttpResponseErrorHook = error => { - if (error instanceof HttpError) { - expectTypeOf(error.statusCode).toEqualTypeOf(); + if (error instanceof HttpResponseError) { + expectTypeOf(error.status).toEqualTypeOf(); } }; From 95c27c64bb3f2b267f126cb872679c6a7f6140a0 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Wed, 19 Aug 2026 17:08:41 +0500 Subject: [PATCH 25/60] feat(http): update errors --- .../http-client/src/adapters/FetchAdapter.ts | 20 ++++++++------ .../http-client/src/adapters/XhrAdapter.ts | 27 ++++++++++--------- .../http-client/src/errors/HttpClientError.ts | 11 +++++++- services/http-client/src/errors/index.ts | 1 + .../src/utilities/getErrorMessage.ts | 3 +++ services/http-client/src/utilities/index.ts | 1 + .../test/core/HttpClient.GET.tests.ts | 5 +++- .../core/HttpClient.abort-timeout.tests.ts | 5 +++- .../core/HttpClient.defaultConfig.tests.ts | 1 + .../test/errors/HttpClientError.tests.ts | 3 ++- .../test/types/HttpHooks.tests-d.ts | 1 + 11 files changed, 54 insertions(+), 24 deletions(-) create mode 100644 services/http-client/src/utilities/getErrorMessage.ts diff --git a/services/http-client/src/adapters/FetchAdapter.ts b/services/http-client/src/adapters/FetchAdapter.ts index ec6e52e3..82661e39 100644 --- a/services/http-client/src/adapters/FetchAdapter.ts +++ b/services/http-client/src/adapters/FetchAdapter.ts @@ -13,7 +13,7 @@ import { THttpHeaders, THttpResponseType } from '../types'; -import { buildUrl, hasHeader, mergeHeaders } from '../utilities'; +import { buildUrl, getErrorMessage, hasHeader, mergeHeaders } from '../utilities'; function extractResponseHeaders(headers: Headers): THttpHeaders { const result: THttpHeaders = {}; @@ -23,7 +23,11 @@ function extractResponseHeaders(headers: Headers): THttpHeaders { return result; } -async function parseResponseBody(response: Response, responseType?: THttpResponseType): Promise { +async function parseResponseBody( + response: Response, + config: IHttpRequestConfig, + responseType?: THttpResponseType +): Promise { if (response.status === 204 || response.headers.get('content-length') === '0') { return undefined as T; } @@ -44,7 +48,7 @@ async function parseResponseBody(response: Response, responseType?: THttpResp try { return JSON.parse(text) as T; } catch (error) { - throw new ParseError('Failed to parse response body as JSON', { cause: error }); + throw new ParseError('Failed to parse response body as JSON', { cause: error, config }); } } } @@ -90,7 +94,7 @@ export class FetchAdapter implements IHttpClientAdapter { if (userSignal) { if (userSignal.aborted) { clearTimeout(timeoutId); - throw new AbortError('Request was aborted'); + throw new AbortError('Request was aborted', { cause: userSignal.reason, config }); } userSignal.addEventListener('abort', () => ownController!.abort(), { once: true }); } @@ -129,7 +133,7 @@ export class FetchAdapter implements IHttpClientAdapter { ); } - const responseData = await parseResponseBody(response, responseType); + const responseData = await parseResponseBody(response, config, responseType); return { data: responseData, @@ -144,14 +148,14 @@ export class FetchAdapter implements IHttpClientAdapter { } if (userSignal?.aborted) { - throw new AbortError('Request was aborted'); + throw new AbortError('Request was aborted', { cause: userSignal.reason ?? error, config }); } if (timeout && error instanceof DOMException && error.name === 'AbortError') { - throw new TimeoutError(`Request timed out after ${timeout}ms`); + throw new TimeoutError(`Request timed out after ${timeout}ms`, { cause: error, config }); } - throw new NetworkError((error as Error).message); + throw new NetworkError(getErrorMessage(error, 'Network request failed'), { cause: error, config }); } finally { if (timeoutId !== undefined) { clearTimeout(timeoutId); diff --git a/services/http-client/src/adapters/XhrAdapter.ts b/services/http-client/src/adapters/XhrAdapter.ts index be902893..e6deafac 100644 --- a/services/http-client/src/adapters/XhrAdapter.ts +++ b/services/http-client/src/adapters/XhrAdapter.ts @@ -13,7 +13,7 @@ import { THttpHeaders, THttpResponseType } from '../types'; -import { buildUrl, hasHeader, mergeHeaders } from '../utilities'; +import { buildUrl, getErrorMessage, hasHeader, mergeHeaders } from '../utilities'; function parseResponseHeaders(rawHeaders: string): THttpHeaders { const result: THttpHeaders = {}; @@ -29,7 +29,7 @@ function parseResponseHeaders(rawHeaders: string): THttpHeaders { return result; } -function getResponseBody(xhr: XMLHttpRequest, responseType?: THttpResponseType): unknown { +function getResponseBody(xhr: XMLHttpRequest, config: IHttpRequestConfig, responseType?: THttpResponseType): unknown { if (xhr.status === 204) { return undefined; } @@ -49,7 +49,7 @@ function getResponseBody(xhr: XMLHttpRequest, responseType?: THttpResponseType): try { return JSON.parse(text); } catch (error) { - throw new ParseError('Failed to parse response body as JSON', { cause: error }); + throw new ParseError('Failed to parse response body as JSON', { cause: error, config }); } } } @@ -73,7 +73,7 @@ export class XhrAdapter implements IHttpClientAdapter { return new Promise>((resolve, reject) => { if (userSignal?.aborted) { - reject(new AbortError('Request was aborted')); + reject(new AbortError('Request was aborted', { cause: userSignal.reason, config })); return; } @@ -130,7 +130,7 @@ export class XhrAdapter implements IHttpClientAdapter { if (xhr.status >= 200 && xhr.status < 300) { try { - const responseData = getResponseBody(xhr, responseType) as T; + const responseData = getResponseBody(xhr, config, responseType) as T; resolve({ data: responseData, status: xhr.status as THttpStatusCode, @@ -142,7 +142,10 @@ export class XhrAdapter implements IHttpClientAdapter { reject( error instanceof HttpClientError ? error - : new NetworkError((error as Error).message, { cause: error }) + : new NetworkError(getErrorMessage(error, 'Network request failed'), { + cause: error, + config + }) ); } } else { @@ -175,19 +178,19 @@ export class XhrAdapter implements IHttpClientAdapter { } }; - xhr.onerror = () => { + xhr.onerror = event => { cleanup(); - reject(new NetworkError('Network request failed')); + reject(new NetworkError('Network request failed', { cause: event, config })); }; - xhr.onabort = () => { + xhr.onabort = event => { cleanup(); - reject(new AbortError('Request was aborted')); + reject(new AbortError('Request was aborted', { cause: userSignal?.reason ?? event, config })); }; - xhr.ontimeout = () => { + xhr.ontimeout = event => { cleanup(); - reject(new TimeoutError(`Request timed out after ${timeout}ms`)); + reject(new TimeoutError(`Request timed out after ${timeout ?? 0}ms`, { cause: event, config })); }; xhr.send(body); diff --git a/services/http-client/src/errors/HttpClientError.ts b/services/http-client/src/errors/HttpClientError.ts index c6636754..b18c4c9d 100644 --- a/services/http-client/src/errors/HttpClientError.ts +++ b/services/http-client/src/errors/HttpClientError.ts @@ -1,8 +1,17 @@ +import { IHttpRequestConfig } from '../types'; + +export interface IHttpClientErrorOptions extends ErrorOptions { + config?: IHttpRequestConfig; +} + /** Базовый класс всех ошибок http-клиента. Позволяет поймать любую ошибку клиента одной проверкой instanceof. */ export class HttpClientError extends Error { - constructor(message: string, options?: ErrorOptions) { + 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); } diff --git a/services/http-client/src/errors/index.ts b/services/http-client/src/errors/index.ts index 0d6131ee..79decefe 100644 --- a/services/http-client/src/errors/index.ts +++ b/services/http-client/src/errors/index.ts @@ -1,5 +1,6 @@ export { AbortError } from './AbortError'; export { HttpClientError } from './HttpClientError'; +export type { IHttpClientErrorOptions } from './HttpClientError'; export { HttpResponseError } from './HttpResponseError'; export { NetworkError } from './NetworkError'; export { ParseError } from './ParseError'; diff --git a/services/http-client/src/utilities/getErrorMessage.ts b/services/http-client/src/utilities/getErrorMessage.ts new file mode 100644 index 00000000..e860154c --- /dev/null +++ b/services/http-client/src/utilities/getErrorMessage.ts @@ -0,0 +1,3 @@ +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 index f8ce42b2..4ce0c855 100644 --- a/services/http-client/src/utilities/index.ts +++ b/services/http-client/src/utilities/index.ts @@ -1,2 +1,3 @@ export { buildUrl } from './buildUrl'; +export { getErrorMessage } from './getErrorMessage'; export { hasHeader, mergeHeaders } from './mergeHeaders'; diff --git a/services/http-client/test/core/HttpClient.GET.tests.ts b/services/http-client/test/core/HttpClient.GET.tests.ts index 59016c17..8297d4f7 100644 --- a/services/http-client/test/core/HttpClient.GET.tests.ts +++ b/services/http-client/test/core/HttpClient.GET.tests.ts @@ -134,7 +134,10 @@ describe.each(adapters)('HttpClient.$name — GET', ({ create }) => { expect.fail('Should have thrown'); } catch (error) { expect(error).toBeInstanceOf(ParseError); - expect((error as ParseError).cause).toBeInstanceOf(SyntaxError); + const parseError = error as ParseError; + + expect(parseError.cause).toBeInstanceOf(SyntaxError); + expect(parseError.config).toMatchObject({ url: '/invalid-json', baseUrl: BASE_URL }); } }); }); diff --git a/services/http-client/test/core/HttpClient.abort-timeout.tests.ts b/services/http-client/test/core/HttpClient.abort-timeout.tests.ts index 5489ecb7..6589b2f4 100644 --- a/services/http-client/test/core/HttpClient.abort-timeout.tests.ts +++ b/services/http-client/test/core/HttpClient.abort-timeout.tests.ts @@ -21,13 +21,15 @@ 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(); + 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'); } }); @@ -137,6 +139,7 @@ describe.each(adapters)('HttpClient.$name — timeout', ({ name, create }) => { } catch (error) { expect(error).toBeInstanceOf(TimeoutError); expect((error as TimeoutError).message).toContain('50'); + expect((error as TimeoutError).config).toMatchObject({ url: '/slow', baseUrl: BASE_URL }); } 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 index 94305bb5..1765e400 100644 --- a/services/http-client/test/core/HttpClient.defaultConfig.tests.ts +++ b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts @@ -87,6 +87,7 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { 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/errors/HttpClientError.tests.ts b/services/http-client/test/errors/HttpClientError.tests.ts index 0a1e0d42..eae04402 100644 --- a/services/http-client/test/errors/HttpClientError.tests.ts +++ b/services/http-client/test/errors/HttpClientError.tests.ts @@ -66,8 +66,9 @@ describe('HttpClientError', () => { it('preserves the cause of a ParseError', () => { const cause = new SyntaxError('Unexpected token'); - const error = new ParseError('Failed to parse response body as JSON', { cause }); + const error = new ParseError('Failed to parse response body as JSON', { cause, config }); expect(error.cause).toBe(cause); + expect(error.config).toBe(config); }); }); diff --git a/services/http-client/test/types/HttpHooks.tests-d.ts b/services/http-client/test/types/HttpHooks.tests-d.ts index 2bbea0ff..0a62d722 100644 --- a/services/http-client/test/types/HttpHooks.tests-d.ts +++ b/services/http-client/test/types/HttpHooks.tests-d.ts @@ -57,6 +57,7 @@ describe('THttpResponseErrorHook', () => { const narrowed: THttpResponseErrorHook = error => { if (error instanceof HttpResponseError) { expectTypeOf(error.status).toEqualTypeOf(); + expectTypeOf(error.config).toEqualTypeOf(); } }; From 0ca0154fa91d961994329b69b0073e174b57d2ab Mon Sep 17 00:00:00 2001 From: abrosimov Date: Wed, 19 Aug 2026 20:17:55 +0500 Subject: [PATCH 26/60] feat(http): add guards --- services/http-client/src/errors/AbortError.ts | 4 ++ .../http-client/src/errors/HttpClientError.ts | 4 ++ .../src/errors/HttpResponseError.ts | 4 ++ .../http-client/src/errors/NetworkError.ts | 4 ++ services/http-client/src/errors/ParseError.ts | 4 ++ .../src/errors/RequestBuilderError.ts | 4 ++ .../http-client/src/errors/TimeoutError.ts | 4 ++ services/http-client/src/errors/index.ts | 14 ++--- .../test/errors/HttpClientError.tests.ts | 36 +++++++++++++ .../test/errors/errorGuards.tests-d.ts | 51 +++++++++++++++++++ 10 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 services/http-client/test/errors/errorGuards.tests-d.ts diff --git a/services/http-client/src/errors/AbortError.ts b/services/http-client/src/errors/AbortError.ts index 3ce462d3..848b51f5 100644 --- a/services/http-client/src/errors/AbortError.ts +++ b/services/http-client/src/errors/AbortError.ts @@ -1,3 +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 index b18c4c9d..ba1499eb 100644 --- a/services/http-client/src/errors/HttpClientError.ts +++ b/services/http-client/src/errors/HttpClientError.ts @@ -16,3 +16,7 @@ export class HttpClientError extends Error { 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 index ef9fa3c8..fbba11fd 100644 --- a/services/http-client/src/errors/HttpResponseError.ts +++ b/services/http-client/src/errors/HttpResponseError.ts @@ -27,3 +27,7 @@ export class HttpResponseError extends HttpClientError { 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 index 2ec3a48c..a18f0f3f 100644 --- a/services/http-client/src/errors/NetworkError.ts +++ b/services/http-client/src/errors/NetworkError.ts @@ -1,3 +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 index 529cadcd..5ca539a8 100644 --- a/services/http-client/src/errors/ParseError.ts +++ b/services/http-client/src/errors/ParseError.ts @@ -2,3 +2,7 @@ import { HttpClientError } from './HttpClientError'; /** Indicates that a successful response could not be parsed in the requested format. */ export class ParseError extends HttpClientError {} + +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 index 43460d4e..b0ecc069 100644 --- a/services/http-client/src/errors/RequestBuilderError.ts +++ b/services/http-client/src/errors/RequestBuilderError.ts @@ -11,3 +11,7 @@ export class RequestBuilderError extends HttpClientError { this.code = code; } } + +export function isRequestBuilderError(error: unknown): error is RequestBuilderError { + return error instanceof RequestBuilderError; +} diff --git a/services/http-client/src/errors/TimeoutError.ts b/services/http-client/src/errors/TimeoutError.ts index c0387c9e..309efa56 100644 --- a/services/http-client/src/errors/TimeoutError.ts +++ b/services/http-client/src/errors/TimeoutError.ts @@ -1,3 +1,7 @@ import { HttpClientError } from './HttpClientError'; export class TimeoutError extends HttpClientError {} + +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 index 79decefe..a531250f 100644 --- a/services/http-client/src/errors/index.ts +++ b/services/http-client/src/errors/index.ts @@ -1,8 +1,8 @@ -export { AbortError } from './AbortError'; -export { HttpClientError } from './HttpClientError'; +export { AbortError, isAbortError } from './AbortError'; +export { HttpClientError, isHttpClientError } from './HttpClientError'; export type { IHttpClientErrorOptions } from './HttpClientError'; -export { HttpResponseError } from './HttpResponseError'; -export { NetworkError } from './NetworkError'; -export { ParseError } from './ParseError'; -export { RequestBuilderError } from './RequestBuilderError'; -export { TimeoutError } from './TimeoutError'; +export { HttpResponseError, isHttpResponseError } from './HttpResponseError'; +export { NetworkError, isNetworkError } from './NetworkError'; +export { ParseError, isParseError } from './ParseError'; +export { RequestBuilderError, isRequestBuilderError } from './RequestBuilderError'; +export { TimeoutError, isTimeoutError } from './TimeoutError'; diff --git a/services/http-client/test/errors/HttpClientError.tests.ts b/services/http-client/test/errors/HttpClientError.tests.ts index eae04402..0a1cc469 100644 --- a/services/http-client/test/errors/HttpClientError.tests.ts +++ b/services/http-client/test/errors/HttpClientError.tests.ts @@ -3,6 +3,13 @@ import { AbortError, HttpClientError, HttpResponseError, + isAbortError, + isHttpClientError, + isHttpResponseError, + isNetworkError, + isParseError, + isRequestBuilderError, + isTimeoutError, NetworkError, ParseError, RequestBuilderError, @@ -28,6 +35,24 @@ const errors: Array<{ name: string; create: () => HttpClientError }> = [ { 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: '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(); @@ -43,6 +68,17 @@ describe('HttpClientError', () => { 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', () => { const headers = { 'x-a': 'b' }; const error = new HttpResponseError('failed', HTTP_STATUS_CODES.NOT_FOUND, 'Not Found', headers, 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..4b78d6b4 --- /dev/null +++ b/services/http-client/test/errors/errorGuards.tests-d.ts @@ -0,0 +1,51 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { + AbortError, + HttpClientError, + HttpResponseError, + isAbortError, + isHttpClientError, + isHttpResponseError, + isNetworkError, + isParseError, + isRequestBuilderError, + isTimeoutError, + NetworkError, + ParseError, + RequestBuilderError, + TimeoutError +} from '../../src/errors'; + +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(); + } + + if (isNetworkError(error)) { + expectTypeOf(error).toEqualTypeOf(); + } + + if (isTimeoutError(error)) { + expectTypeOf(error).toEqualTypeOf(); + } + + if (isAbortError(error)) { + expectTypeOf(error).toEqualTypeOf(); + } + + if (isParseError(error)) { + expectTypeOf(error).toEqualTypeOf(); + } + + if (isRequestBuilderError(error)) { + expectTypeOf(error).toEqualTypeOf(); + } + }); +}); From 560067a4ec39f47e0626086fb5e0d92e8fd8a8dd Mon Sep 17 00:00:00 2001 From: abrosimov Date: Wed, 19 Aug 2026 20:26:05 +0500 Subject: [PATCH 27/60] feat(http): fix build --- services/http-client/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/http-client/package.json b/services/http-client/package.json index 5866e77e..bd5b158c 100644 --- a/services/http-client/package.json +++ b/services/http-client/package.json @@ -20,7 +20,7 @@ "url": "git+https://github.com/Byndyusoft/ui.git" }, "scripts": { - "build": "tsc --project tsconfig.build.json", + "build": "rimraf dist && tsc --project tsconfig.build.json", "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" From 0e31d1bdef9a149fe6fe1904d467bcda5995e5c1 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Wed, 19 Aug 2026 20:42:18 +0500 Subject: [PATCH 28/60] feat(http): coerce to number --- .../http-client/src/adapters/FetchAdapter.ts | 13 +++---------- services/http-client/src/adapters/XhrAdapter.ts | 13 +++---------- services/http-client/src/types/HttpStatusCode.ts | 5 ++--- .../http-client/test/types/HttpHooks.tests-d.ts | 16 ++++++++++++++++ 4 files changed, 24 insertions(+), 23 deletions(-) diff --git a/services/http-client/src/adapters/FetchAdapter.ts b/services/http-client/src/adapters/FetchAdapter.ts index 82661e39..5b2cffbe 100644 --- a/services/http-client/src/adapters/FetchAdapter.ts +++ b/services/http-client/src/adapters/FetchAdapter.ts @@ -5,14 +5,7 @@ import { NetworkError } from '../errors/NetworkError'; import { ParseError } from '../errors/ParseError'; import { TimeoutError } from '../errors/TimeoutError'; import { AbortError } from '../errors/AbortError'; -import { - IHttpClientAdapter, - IHttpRequestConfig, - IHttpResponse, - THttpStatusCode, - THttpHeaders, - THttpResponseType -} from '../types'; +import { IHttpClientAdapter, IHttpRequestConfig, IHttpResponse, THttpHeaders, THttpResponseType } from '../types'; import { buildUrl, getErrorMessage, hasHeader, mergeHeaders } from '../utilities'; function extractResponseHeaders(headers: Headers): THttpHeaders { @@ -125,7 +118,7 @@ export class FetchAdapter implements IHttpClientAdapter { throw new HttpResponseError( `Request failed with status code ${response.status}`, - response.status as THttpStatusCode, + response.status, response.statusText, extractResponseHeaders(response.headers), config, @@ -137,7 +130,7 @@ export class FetchAdapter implements IHttpClientAdapter { return { data: responseData, - status: response.status as THttpStatusCode, + status: response.status, statusText: response.statusText, headers: extractResponseHeaders(response.headers), config diff --git a/services/http-client/src/adapters/XhrAdapter.ts b/services/http-client/src/adapters/XhrAdapter.ts index e6deafac..d1d14d74 100644 --- a/services/http-client/src/adapters/XhrAdapter.ts +++ b/services/http-client/src/adapters/XhrAdapter.ts @@ -5,14 +5,7 @@ import { NetworkError } from '../errors/NetworkError'; import { ParseError } from '../errors/ParseError'; import { TimeoutError } from '../errors/TimeoutError'; import { AbortError } from '../errors/AbortError'; -import { - IHttpClientAdapter, - IHttpRequestConfig, - IHttpResponse, - THttpStatusCode, - THttpHeaders, - THttpResponseType -} from '../types'; +import { IHttpClientAdapter, IHttpRequestConfig, IHttpResponse, THttpHeaders, THttpResponseType } from '../types'; import { buildUrl, getErrorMessage, hasHeader, mergeHeaders } from '../utilities'; function parseResponseHeaders(rawHeaders: string): THttpHeaders { @@ -133,7 +126,7 @@ export class XhrAdapter implements IHttpClientAdapter { const responseData = getResponseBody(xhr, config, responseType) as T; resolve({ data: responseData, - status: xhr.status as THttpStatusCode, + status: xhr.status, statusText: xhr.statusText, headers: parseResponseHeaders(xhr.getAllResponseHeaders()), config @@ -168,7 +161,7 @@ export class XhrAdapter implements IHttpClientAdapter { reject( new HttpResponseError( `Request failed with status code ${xhr.status}`, - xhr.status as THttpStatusCode, + xhr.status, xhr.statusText, parseResponseHeaders(xhr.getAllResponseHeaders()), config, diff --git a/services/http-client/src/types/HttpStatusCode.ts b/services/http-client/src/types/HttpStatusCode.ts index 724312bb..5bf8a884 100644 --- a/services/http-client/src/types/HttpStatusCode.ts +++ b/services/http-client/src/types/HttpStatusCode.ts @@ -1,3 +1,2 @@ -import { HTTP_STATUS_CODES } from '../constants'; - -export type THttpStatusCode = typeof HTTP_STATUS_CODES[keyof typeof HTTP_STATUS_CODES]; \ No newline at end of file +/** Any valid HTTP response status, including non-standard codes returned by proxies or servers. */ +export type THttpStatusCode = number; diff --git a/services/http-client/test/types/HttpHooks.tests-d.ts b/services/http-client/test/types/HttpHooks.tests-d.ts index 0a62d722..2d7b6f09 100644 --- a/services/http-client/test/types/HttpHooks.tests-d.ts +++ b/services/http-client/test/types/HttpHooks.tests-d.ts @@ -18,6 +18,13 @@ const response: IHttpResponse = { 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]; @@ -34,6 +41,15 @@ describe('THttpResponseHook', () => { }); }); +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[] = [ From 4261487daeba1e2ca1e1ba53b243c32e09a0f643 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Wed, 19 Aug 2026 20:50:25 +0500 Subject: [PATCH 29/60] feat(http): remove connect and trace --- services/http-client/src/constants/httpMethods.ts | 4 +--- services/http-client/src/core/HttpClient.ts | 8 -------- .../http-client/test/core/HttpRequestBuilder.tests.ts | 10 ++++++++++ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/services/http-client/src/constants/httpMethods.ts b/services/http-client/src/constants/httpMethods.ts index c39fb1bc..a44489c0 100644 --- a/services/http-client/src/constants/httpMethods.ts +++ b/services/http-client/src/constants/httpMethods.ts @@ -4,8 +4,6 @@ export const HTTP_METHODS = { POST: 'POST', PUT: 'PUT', DELETE: 'DELETE', - CONNECT: 'CONNECT', OPTIONS: 'OPTIONS', - TRACE: 'TRACE', PATCH: 'PATCH' -} as const; \ No newline at end of file +} as const; diff --git a/services/http-client/src/core/HttpClient.ts b/services/http-client/src/core/HttpClient.ts index 5bc2c7e1..23d5989f 100644 --- a/services/http-client/src/core/HttpClient.ts +++ b/services/http-client/src/core/HttpClient.ts @@ -91,18 +91,10 @@ export class HttpClient { return this.request(HTTP_METHODS.DELETE, url); } - public connect(url: string): HttpRequestBuilder { - return this.request(HTTP_METHODS.CONNECT, url); - } - public options(url: string): HttpRequestBuilder { return this.request(HTTP_METHODS.OPTIONS, url); } - public trace(url: string): HttpRequestBuilder { - return this.request(HTTP_METHODS.TRACE, url); - } - public patch(url: string): HttpRequestBuilder { return this.request(HTTP_METHODS.PATCH, url); } diff --git a/services/http-client/test/core/HttpRequestBuilder.tests.ts b/services/http-client/test/core/HttpRequestBuilder.tests.ts index febc0052..e5028adc 100644 --- a/services/http-client/test/core/HttpRequestBuilder.tests.ts +++ b/services/http-client/test/core/HttpRequestBuilder.tests.ts @@ -127,6 +127,16 @@ describe('HttpRequestBuilder', () => { 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, ' '), From bbcb37248ef724495b854453690f13c4c480bff3 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Wed, 19 Aug 2026 21:10:11 +0500 Subject: [PATCH 30/60] feat(http): add default params --- services/http-client/src/core/HttpClient.ts | 5 ++- .../src/core/HttpRequestBuilder.ts | 21 ++-------- .../src/types/HttpClientOptions.ts | 2 + services/http-client/src/utilities/index.ts | 1 + .../http-client/src/utilities/mergeParams.ts | 18 ++++++++ .../HttpClient.defaultConfig.handlers.ts | 7 +++- .../core/HttpClient.defaultConfig.tests.ts | 41 +++++++++++++++++++ 7 files changed, 75 insertions(+), 20 deletions(-) create mode 100644 services/http-client/src/utilities/mergeParams.ts diff --git a/services/http-client/src/core/HttpClient.ts b/services/http-client/src/core/HttpClient.ts index 23d5989f..ca1bd90c 100644 --- a/services/http-client/src/core/HttpClient.ts +++ b/services/http-client/src/core/HttpClient.ts @@ -12,7 +12,7 @@ import { THttpResponseErrorHook } from '../types'; import { HttpRequestBuilder } from './HttpRequestBuilder'; -import { mergeHeaders } from '../utilities'; +import { mergeHeaders, mergeParams } from '../utilities'; export class HttpClient { private readonly adapter: IHttpClientAdapter; @@ -32,6 +32,7 @@ export class HttpClient { this.defaultConfig = { baseUrl: options.baseUrl, headers: options.headers, + params: mergeParams(options.params), timeout: options.timeout }; } @@ -112,7 +113,7 @@ export class HttpClient { baseUrl: config.baseUrl ?? this.defaultConfig.baseUrl, timeout: config.timeout ?? this.defaultConfig.timeout, headers: mergeHeaders(this.defaultConfig.headers, config.headers), - params: { ...this.defaultConfig.params, ...config.params } + params: mergeParams(this.defaultConfig.params, config.params) }; if (onRequest !== undefined) { diff --git a/services/http-client/src/core/HttpRequestBuilder.ts b/services/http-client/src/core/HttpRequestBuilder.ts index 89b5d03f..f8faa12e 100644 --- a/services/http-client/src/core/HttpRequestBuilder.ts +++ b/services/http-client/src/core/HttpRequestBuilder.ts @@ -25,25 +25,15 @@ import { assertValidTimeout, assertValidUrl } from '../asserts'; -import { mergeHeaders } from '../utilities'; +import { mergeHeaders, mergeParams } from '../utilities'; type TBuilderConfigPatch = Partial>; -function cloneParams(params: THttpParams): THttpParams { - const result: THttpParams = {}; - - for (const [key, value] of Object.entries(params)) { - result[key] = Array.isArray(value) ? [...value] : value; - } - - return result; -} - function cloneConfig(config: IHttpRequestConfig): IHttpRequestConfig { return { ...config, ...(config.headers === undefined ? {} : { headers: { ...config.headers } }), - ...(config.params === undefined ? {} : { params: cloneParams(config.params) }) + ...(config.params === undefined ? {} : { params: mergeParams(config.params) }) }; } @@ -130,10 +120,7 @@ export class HttpRequestBuilder { assertValidParam(key, value); return this.withConfig({ - params: { - ...cloneParams(this.config.params ?? {}), - [key]: Array.isArray(value) ? [...value] : value - } + params: mergeParams(this.config.params, { [key]: value }) }); } @@ -141,7 +128,7 @@ export class HttpRequestBuilder { assertValidParams(params); return this.withConfig({ - params: { ...cloneParams(this.config.params ?? {}), ...cloneParams(params) } + params: mergeParams(this.config.params, params) }); } diff --git a/services/http-client/src/types/HttpClientOptions.ts b/services/http-client/src/types/HttpClientOptions.ts index 1c7efca2..11301251 100644 --- a/services/http-client/src/types/HttpClientOptions.ts +++ b/services/http-client/src/types/HttpClientOptions.ts @@ -1,11 +1,13 @@ import { IHttpClientAdapter } from './HttpClientAdapter'; import { THttpHeaders } from './HttpHeaders'; import { THttpRequestErrorHook, THttpRequestHook, THttpResponseErrorHook, THttpResponseHook } from './HttpHooks'; +import { THttpParams } from './HttpParams'; export interface IHttpClientOptions { adapter: IHttpClientAdapter; baseUrl?: string; headers?: THttpHeaders; + params?: THttpParams; timeout?: number; onRequest?: THttpRequestHook; onRequestError?: THttpRequestErrorHook; diff --git a/services/http-client/src/utilities/index.ts b/services/http-client/src/utilities/index.ts index 4ce0c855..8abeae88 100644 --- a/services/http-client/src/utilities/index.ts +++ b/services/http-client/src/utilities/index.ts @@ -1,3 +1,4 @@ export { buildUrl } from './buildUrl'; export { getErrorMessage } from './getErrorMessage'; export { hasHeader, mergeHeaders } from './mergeHeaders'; +export { mergeParams } from './mergeParams'; diff --git a/services/http-client/src/utilities/mergeParams.ts b/services/http-client/src/utilities/mergeParams.ts new file mode 100644 index 00000000..2350110c --- /dev/null +++ b/services/http-client/src/utilities/mergeParams.ts @@ -0,0 +1,18 @@ +import { THttpParams } from '../types'; + +/** Merges query params from left to right and copies array values. */ +export function mergeParams(...sources: Array): THttpParams { + const result: THttpParams = {}; + + for (const source of sources) { + if (source === undefined) { + continue; + } + + for (const [key, value] of Object.entries(source)) { + result[key] = Array.isArray(value) ? [...value] : value; + } + } + + return result; +} diff --git a/services/http-client/test/__handlers__/HttpClient.defaultConfig.handlers.ts b/services/http-client/test/__handlers__/HttpClient.defaultConfig.handlers.ts index 94427aa0..cd146b73 100644 --- a/services/http-client/test/__handlers__/HttpClient.defaultConfig.handlers.ts +++ b/services/http-client/test/__handlers__/HttpClient.defaultConfig.handlers.ts @@ -3,10 +3,15 @@ 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') + authorization: request.headers.get('authorization'), + locale: url.searchParams.get('locale'), + page: url.searchParams.get('page'), + role: url.searchParams.getAll('role') }); }), diff --git a/services/http-client/test/core/HttpClient.defaultConfig.tests.ts b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts index 1765e400..822111a4 100644 --- a/services/http-client/test/core/HttpClient.defaultConfig.tests.ts +++ b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts @@ -60,6 +60,47 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { 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') + .execute<{ locale: string | null; page: string | null; role: string[] }>(); + + 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'] }) + .execute<{ locale: string | null; page: string | null; role: string[] }>(); + + expect(response.data).toMatchObject({ locale: 'ru', page: '2', role: ['editor'] }); + }); + + 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').execute<{ locale: string | null; role: string[] }>(); + + expect(response.data).toMatchObject({ locale: 'ru', role: ['admin'] }); + }); + test('uses default baseUrl', async () => { const client = createClient(); const response = await client.get('/base-test').execute<{ ok: boolean }>(); From 269d06608e267f33e7fa72de771ab5b7145cdacf Mon Sep 17 00:00:00 2001 From: abrosimov Date: Wed, 19 Aug 2026 21:22:03 +0500 Subject: [PATCH 31/60] feat(http): support formData and stream --- .../http-client/src/adapters/FetchAdapter.ts | 8 + .../http-client/src/adapters/XhrAdapter.ts | 165 ++++++++++++++++-- .../__handlers__/HttpClient.GET.handlers.ts | 10 ++ .../test/core/HttpClient.GET.tests.ts | 23 +++ 4 files changed, 190 insertions(+), 16 deletions(-) diff --git a/services/http-client/src/adapters/FetchAdapter.ts b/services/http-client/src/adapters/FetchAdapter.ts index 5b2cffbe..7ebd94ac 100644 --- a/services/http-client/src/adapters/FetchAdapter.ts +++ b/services/http-client/src/adapters/FetchAdapter.ts @@ -32,6 +32,14 @@ async function parseResponseBody( return response.blob() as Promise; case HTTP_RESPONSE_TYPES.ARRAY_BUFFER: return response.arrayBuffer() as Promise; + case HTTP_RESPONSE_TYPES.FORM_DATA: + try { + return (await response.formData()) as T; + } catch (error) { + throw new ParseError('Failed to parse response body as FormData', { cause: error, config }); + } + case HTTP_RESPONSE_TYPES.STREAM: + return response.body as T; case HTTP_RESPONSE_TYPES.JSON: default: { const text = await response.text(); diff --git a/services/http-client/src/adapters/XhrAdapter.ts b/services/http-client/src/adapters/XhrAdapter.ts index d1d14d74..fc006e19 100644 --- a/services/http-client/src/adapters/XhrAdapter.ts +++ b/services/http-client/src/adapters/XhrAdapter.ts @@ -22,25 +22,101 @@ function parseResponseHeaders(rawHeaders: string): THttpHeaders { return result; } -function getResponseBody(xhr: XMLHttpRequest, config: IHttpRequestConfig, responseType?: THttpResponseType): unknown { +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 readBlob(blob: Blob): Promise { + if (typeof blob.arrayBuffer === 'function') { + return blob.arrayBuffer(); + } + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => { + if (typeof reader.result === 'string' || reader.result === null) { + reject(new TypeError('Failed to read response Blob as an ArrayBuffer')); + return; + } + + resolve(reader.result); + }; + reader.onerror = () => { + reject(reader.error ?? new TypeError('Failed to read response Blob as an ArrayBuffer')); + }; + reader.readAsArrayBuffer(blob); + }); +} + +async function getResponseBody( + xhr: XMLHttpRequest, + config: IHttpRequestConfig, + responseType?: THttpResponseType +): Promise { if (xhr.status === 204) { - return undefined; + return undefined as T; } switch (responseType) { case HTTP_RESPONSE_TYPES.ARRAY_BUFFER: case HTTP_RESPONSE_TYPES.BLOB: - return xhr.response; + return xhr.response as T; case HTTP_RESPONSE_TYPES.TEXT: - return xhr.response; + return xhr.response as T; + case HTTP_RESPONSE_TYPES.FORM_DATA: + try { + const body = xhr.response as Blob; + const response = new Response(await readBlob(body), { + headers: parseResponseHeaders(xhr.getAllResponseHeaders()) + }); + + return (await response.formData()) as T; + } catch (error) { + throw new ParseError('Failed to parse response body as FormData', { cause: error, config }); + } case HTTP_RESPONSE_TYPES.JSON: default: { const text = xhr.response as string; if (!text) { - return undefined; + return undefined as T; } try { - return JSON.parse(text); + return JSON.parse(text) as T; } catch (error) { throw new ParseError('Failed to parse response body as JSON', { cause: error, config }); } @@ -70,6 +146,11 @@ export class XhrAdapter implements IHttpClientAdapter { return; } + if (responseType === HTTP_RESPONSE_TYPES.STREAM && typeof ReadableStream === 'undefined') { + reject(new ParseError('Streaming responses are not supported in this environment', { config })); + return; + } + const xhr = new XMLHttpRequest(); xhr.open(method, fullUrl, true); @@ -80,7 +161,7 @@ export class XhrAdapter implements IHttpClientAdapter { if (responseType === HTTP_RESPONSE_TYPES.ARRAY_BUFFER) { // XHR принимает только DOM-значение в нижнем регистре xhr.responseType = 'arraybuffer'; - } else if (responseType === HTTP_RESPONSE_TYPES.BLOB) { + } else if (responseType === HTTP_RESPONSE_TYPES.BLOB || responseType === HTTP_RESPONSE_TYPES.FORM_DATA) { xhr.responseType = 'blob'; } else { xhr.responseType = 'text'; @@ -118,12 +199,63 @@ export class XhrAdapter implements IHttpClientAdapter { } }; - xhr.onload = () => { + let responseStream: IXhrResponseStream | undefined; + let streamResponseResolved = false; + const isSuccessful = (): boolean => xhr.status >= 200 && xhr.status < 300; + const resolveStream = (): void => { + if (streamResponseResolved || !isSuccessful()) { + return; + } + + responseStream = createResponseStream(xhr); + streamResponseResolved = true; + resolve({ + data: responseStream.data as T, + status: xhr.status, + statusText: xhr.statusText, + headers: parseResponseHeaders(xhr.getAllResponseHeaders()), + config + }); + }; + + if (responseType === HTTP_RESPONSE_TYPES.STREAM) { + xhr.onreadystatechange = () => { + if (xhr.readyState === XMLHttpRequest.HEADERS_RECEIVED) { + resolveStream(); + } + }; + + xhr.onprogress = () => { + responseStream?.append(); + }; + } + + const rejectOrFailStream = (error: HttpClientError): void => { cleanup(); - if (xhr.status >= 200 && xhr.status < 300) { + if (streamResponseResolved) { + responseStream?.error(error); + return; + } + + reject(error); + }; + + xhr.onload = async () => { + cleanup(); + + if (isSuccessful()) { + if (responseType === HTTP_RESPONSE_TYPES.STREAM) { + resolveStream(); + responseStream?.append(); + responseStream?.close(); + + return; + } + try { - const responseData = getResponseBody(xhr, config, responseType) as T; + const responseData = await getResponseBody(xhr, config, responseType); + resolve({ data: responseData, status: xhr.status, @@ -172,18 +304,19 @@ export class XhrAdapter implements IHttpClientAdapter { }; xhr.onerror = event => { - cleanup(); - reject(new NetworkError('Network request failed', { cause: event, config })); + rejectOrFailStream(new NetworkError('Network request failed', { cause: event, config })); }; xhr.onabort = event => { - cleanup(); - reject(new AbortError('Request was aborted', { cause: userSignal?.reason ?? event, config })); + rejectOrFailStream( + new AbortError('Request was aborted', { cause: userSignal?.reason ?? event, config }) + ); }; xhr.ontimeout = event => { - cleanup(); - reject(new TimeoutError(`Request timed out after ${timeout ?? 0}ms`, { cause: event, config })); + rejectOrFailStream( + new TimeoutError(`Request timed out after ${timeout ?? 0}ms`, { cause: event, config }) + ); }; xhr.send(body); diff --git a/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts index 26695085..00e309d3 100644 --- a/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts +++ b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts @@ -35,6 +35,16 @@ export const handlers = [ return new HttpResponse(bytes.buffer, { headers: { 'Content-Type': 'application/octet-stream' } }); }), + http.get(`${BASE_URL}/form-data`, () => { + return new HttpResponse('name=John&role=admin&role=editor', { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }); + }), + + 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' }, diff --git a/services/http-client/test/core/HttpClient.GET.tests.ts b/services/http-client/test/core/HttpClient.GET.tests.ts index 8297d4f7..ada4bf45 100644 --- a/services/http-client/test/core/HttpClient.GET.tests.ts +++ b/services/http-client/test/core/HttpClient.GET.tests.ts @@ -108,6 +108,29 @@ describe.each(adapters)('HttpClient.$name — GET', ({ create }) => { expect(Array.from(new Uint8Array(response.data as ArrayBuffer))).toEqual([1, 2, 3, 4]); }); + test('returns FormData when responseType is formData', async () => { + const client = createClient(); + const response = await client.get('/form-data').responseType(HTTP_RESPONSE_TYPES.FORM_DATA).execute(); + + expect(response.data?.get('name')).toBe('John'); + expect(response.data?.getAll('role')).toEqual(['admin', 'editor']); + }); + + test('returns a readable stream when responseType is stream', async () => { + const client = createClient(); + const response = await client + .get('/stream') + .responseType(HTTP_RESPONSE_TYPES.STREAM) + .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 HttpResponseError with response context and data on 404', async () => { const client = createClient(); From 688af93f995f675c913248dfc75240de46764115 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Wed, 19 Aug 2026 22:31:41 +0500 Subject: [PATCH 32/60] feat(http): improve error handling --- .../http-client/src/adapters/FetchAdapter.ts | 151 ++++++--- .../http-client/src/adapters/XhrAdapter.ts | 310 ++++++++++-------- .../src/asserts/assertValidRequestConfig.ts | 53 +++ services/http-client/src/asserts/index.ts | 1 + services/http-client/src/core/HttpClient.ts | 6 +- .../src/core/HttpRequestBuilder.ts | 32 +- .../src/errors/RequestPreparationError.ts | 8 + services/http-client/src/errors/index.ts | 1 + .../test/core/HttpClient.hooks.tests.ts | 100 +++++- .../test/errors/HttpClientError.tests.ts | 16 + .../test/errors/errorGuards.tests-d.ts | 6 + 11 files changed, 453 insertions(+), 231 deletions(-) create mode 100644 services/http-client/src/asserts/assertValidRequestConfig.ts create mode 100644 services/http-client/src/errors/RequestPreparationError.ts diff --git a/services/http-client/src/adapters/FetchAdapter.ts b/services/http-client/src/adapters/FetchAdapter.ts index 7ebd94ac..84dc908b 100644 --- a/services/http-client/src/adapters/FetchAdapter.ts +++ b/services/http-client/src/adapters/FetchAdapter.ts @@ -3,6 +3,7 @@ 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, THttpHeaders, THttpResponseType } from '../types'; @@ -16,6 +17,84 @@ function extractResponseHeaders(headers: Headers): THttpHeaders { 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); + let body: BodyInit | undefined; + + if (data !== undefined && method !== HTTP_METHODS.GET && method !== HTTP_METHODS.HEAD) { + if (typeof data === 'string' || data instanceof ArrayBuffer || data instanceof Blob) { + body = data as BodyInit; + } else { + body = JSON.stringify(data); + if (!hasHeader(requestHeaders, 'Content-Type')) { + requestHeaders['Content-Type'] = 'application/json'; + } + } + } + + 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; + } +} + async function parseResponseBody( response: Response, config: IHttpRequestConfig, @@ -57,58 +136,24 @@ async function parseResponseBody( export class FetchAdapter implements IHttpClientAdapter { async request(config: IHttpRequestConfig): Promise> { - const { - url, - method, - headers = {}, - params, - data, - signal: userSignal, - timeout, - responseType, - baseUrl: baseURL - } = config; - - const fullUrl = buildUrl(baseURL, url, params); - - const requestHeaders = mergeHeaders(headers); - let body: BodyInit | undefined; + let request: IPreparedFetchRequest; - if (data !== undefined && method !== HTTP_METHODS.GET && method !== HTTP_METHODS.HEAD) { - if (typeof data === 'string' || data instanceof ArrayBuffer || data instanceof Blob) { - body = data as BodyInit; - } else { - body = JSON.stringify(data); - if (!hasHeader(requestHeaders, 'Content-Type')) { - requestHeaders['Content-Type'] = 'application/json'; - } + try { + request = prepareFetchRequest(config); + } catch (error) { + if (error instanceof HttpClientError) { + throw error; } - } - - let ownController: AbortController | undefined; - let timeoutId: ReturnType | undefined; - - if (timeout) { - ownController = new AbortController(); - timeoutId = setTimeout(() => ownController!.abort(), timeout); - if (userSignal) { - if (userSignal.aborted) { - clearTimeout(timeoutId); - throw new AbortError('Request was aborted', { cause: userSignal.reason, config }); - } - userSignal.addEventListener('abort', () => ownController!.abort(), { once: true }); - } + throw new RequestPreparationError('Failed to prepare HTTP request', { cause: error, config }); } - const requestSignal = ownController?.signal ?? userSignal; - try { - const response = await fetch(fullUrl, { - method, - headers: requestHeaders, - body, - signal: requestSignal + const response = await fetch(request.fullUrl, { + method: config.method, + headers: request.requestHeaders, + body: request.body, + signal: request.signal }); if (!response.ok) { @@ -134,7 +179,7 @@ export class FetchAdapter implements IHttpClientAdapter { ); } - const responseData = await parseResponseBody(response, config, responseType); + const responseData = await parseResponseBody(response, config, config.responseType); return { data: responseData, @@ -148,19 +193,17 @@ export class FetchAdapter implements IHttpClientAdapter { throw error; } - if (userSignal?.aborted) { - throw new AbortError('Request was aborted', { cause: userSignal.reason ?? error, config }); + if (request.userSignal?.aborted) { + throw new AbortError('Request was aborted', { cause: request.userSignal.reason ?? error, config }); } - if (timeout && error instanceof DOMException && error.name === 'AbortError') { - throw new TimeoutError(`Request timed out after ${timeout}ms`, { cause: error, config }); + if (request.timeout && error instanceof DOMException && error.name === 'AbortError') { + throw new TimeoutError(`Request timed out after ${request.timeout}ms`, { cause: error, config }); } throw new NetworkError(getErrorMessage(error, 'Network request failed'), { cause: error, config }); } finally { - if (timeoutId !== undefined) { - clearTimeout(timeoutId); - } + request.cleanup(); } } } diff --git a/services/http-client/src/adapters/XhrAdapter.ts b/services/http-client/src/adapters/XhrAdapter.ts index fc006e19..07f44b97 100644 --- a/services/http-client/src/adapters/XhrAdapter.ts +++ b/services/http-client/src/adapters/XhrAdapter.ts @@ -3,6 +3,7 @@ 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, THttpHeaders, THttpResponseType } from '../types'; @@ -138,188 +139,209 @@ export class XhrAdapter implements IHttpClientAdapter { baseUrl: baseURL } = config; - const fullUrl = buildUrl(baseURL, url, params); - return new Promise>((resolve, reject) => { - if (userSignal?.aborted) { - reject(new AbortError('Request was aborted', { cause: userSignal.reason, config })); - return; - } + let cleanup: () => void = () => undefined; - if (responseType === HTTP_RESPONSE_TYPES.STREAM && typeof ReadableStream === 'undefined') { - reject(new ParseError('Streaming responses are not supported in this environment', { config })); - return; - } + try { + const fullUrl = buildUrl(baseURL, url, params); - const xhr = new XMLHttpRequest(); - xhr.open(method, fullUrl, true); + if (userSignal?.aborted) { + reject(new AbortError('Request was aborted', { cause: userSignal.reason, config })); + return; + } - if (timeout !== undefined && timeout > 0) { - xhr.timeout = timeout; - } + if ( + responseType === HTTP_RESPONSE_TYPES.STREAM && + (typeof ReadableStream === 'undefined' || typeof TextEncoder === 'undefined') + ) { + reject( + new RequestPreparationError('Streaming responses are not supported in this environment', { + config + }) + ); + return; + } - if (responseType === HTTP_RESPONSE_TYPES.ARRAY_BUFFER) { - // XHR принимает только DOM-значение в нижнем регистре - xhr.responseType = 'arraybuffer'; - } else if (responseType === HTTP_RESPONSE_TYPES.BLOB || responseType === HTTP_RESPONSE_TYPES.FORM_DATA) { - xhr.responseType = 'blob'; - } else { - xhr.responseType = 'text'; - } + const xhr = new XMLHttpRequest(); + xhr.open(method, fullUrl, true); - const requestHeaders = mergeHeaders(headers); - if (data !== undefined && method !== HTTP_METHODS.GET && method !== HTTP_METHODS.HEAD) { - if (typeof data !== 'string' && !(data instanceof ArrayBuffer) && !(data instanceof Blob)) { - if (!hasHeader(requestHeaders, 'Content-Type')) { - requestHeaders['Content-Type'] = 'application/json'; - } + if (timeout !== undefined && timeout > 0) { + xhr.timeout = timeout; } - } - for (const [key, value] of Object.entries(requestHeaders)) { - xhr.setRequestHeader(key, value); - } - let body: string | ArrayBuffer | Blob | undefined; - if (data !== undefined && method !== HTTP_METHODS.GET && method !== HTTP_METHODS.HEAD) { - if (typeof data === 'string' || data instanceof ArrayBuffer || data instanceof Blob) { - body = data; + if (responseType === HTTP_RESPONSE_TYPES.ARRAY_BUFFER) { + // XHR принимает только DOM-значение в нижнем регистре + xhr.responseType = 'arraybuffer'; + } else if ( + responseType === HTTP_RESPONSE_TYPES.BLOB || + responseType === HTTP_RESPONSE_TYPES.FORM_DATA + ) { + xhr.responseType = 'blob'; } else { - body = JSON.stringify(data); + xhr.responseType = 'text'; } - } - - const onAbort = (): void => xhr.abort(); - if (userSignal) { - userSignal.addEventListener('abort', onAbort, { once: true }); - } - const cleanup = (): void => { - if (userSignal) { - userSignal.removeEventListener('abort', onAbort); + const requestHeaders = mergeHeaders(headers); + if (data !== undefined && method !== HTTP_METHODS.GET && method !== HTTP_METHODS.HEAD) { + if (typeof data !== 'string' && !(data instanceof ArrayBuffer) && !(data instanceof Blob)) { + if (!hasHeader(requestHeaders, 'Content-Type')) { + requestHeaders['Content-Type'] = 'application/json'; + } + } + } + for (const [key, value] of Object.entries(requestHeaders)) { + xhr.setRequestHeader(key, value); } - }; - let responseStream: IXhrResponseStream | undefined; - let streamResponseResolved = false; - const isSuccessful = (): boolean => xhr.status >= 200 && xhr.status < 300; - const resolveStream = (): void => { - if (streamResponseResolved || !isSuccessful()) { - return; + let body: string | ArrayBuffer | Blob | undefined; + if (data !== undefined && method !== HTTP_METHODS.GET && method !== HTTP_METHODS.HEAD) { + if (typeof data === 'string' || data instanceof ArrayBuffer || data instanceof Blob) { + body = data; + } else { + body = JSON.stringify(data); + } } - responseStream = createResponseStream(xhr); - streamResponseResolved = true; - resolve({ - data: responseStream.data as T, - status: xhr.status, - statusText: xhr.statusText, - headers: parseResponseHeaders(xhr.getAllResponseHeaders()), - config - }); - }; + const onAbort = (): void => xhr.abort(); + if (userSignal) { + userSignal.addEventListener('abort', onAbort, { once: true }); + } - if (responseType === HTTP_RESPONSE_TYPES.STREAM) { - xhr.onreadystatechange = () => { - if (xhr.readyState === XMLHttpRequest.HEADERS_RECEIVED) { - resolveStream(); + cleanup = (): void => { + if (userSignal) { + userSignal.removeEventListener('abort', onAbort); } }; - xhr.onprogress = () => { - responseStream?.append(); + let responseStream: IXhrResponseStream | undefined; + let streamResponseResolved = false; + const isSuccessful = (): boolean => xhr.status >= 200 && xhr.status < 300; + const resolveStream = (): void => { + if (streamResponseResolved || !isSuccessful()) { + return; + } + + responseStream = createResponseStream(xhr); + streamResponseResolved = true; + resolve({ + data: responseStream.data as T, + status: xhr.status, + statusText: xhr.statusText, + headers: parseResponseHeaders(xhr.getAllResponseHeaders()), + config + }); }; - } - const rejectOrFailStream = (error: HttpClientError): void => { - cleanup(); + if (responseType === HTTP_RESPONSE_TYPES.STREAM) { + xhr.onreadystatechange = () => { + if (xhr.readyState === XMLHttpRequest.HEADERS_RECEIVED) { + resolveStream(); + } + }; - if (streamResponseResolved) { - responseStream?.error(error); - return; + xhr.onprogress = () => { + responseStream?.append(); + }; } - reject(error); - }; - - xhr.onload = async () => { - cleanup(); - - if (isSuccessful()) { - if (responseType === HTTP_RESPONSE_TYPES.STREAM) { - resolveStream(); - responseStream?.append(); - responseStream?.close(); + const rejectOrFailStream = (error: HttpClientError): void => { + cleanup(); + if (streamResponseResolved) { + responseStream?.error(error); return; } - try { - const responseData = await getResponseBody(xhr, config, responseType); + reject(error); + }; - resolve({ - data: responseData, - status: xhr.status, - statusText: xhr.statusText, - headers: parseResponseHeaders(xhr.getAllResponseHeaders()), - config - }); - } catch (error) { - reject( - error instanceof HttpClientError - ? error - : new NetworkError(getErrorMessage(error, 'Network request failed'), { - cause: error, - config - }) - ); - } - } else { - let errorData: unknown; - try { - if (xhr.responseType === 'text') { - const text = xhr.responseText; - if (text) { - try { - errorData = JSON.parse(text); - } catch { - errorData = text; + xhr.onload = async () => { + cleanup(); + + if (isSuccessful()) { + if (responseType === HTTP_RESPONSE_TYPES.STREAM) { + resolveStream(); + responseStream?.append(); + responseStream?.close(); + + return; + } + + try { + const responseData = await getResponseBody(xhr, config, responseType); + + resolve({ + data: responseData, + status: xhr.status, + statusText: xhr.statusText, + headers: parseResponseHeaders(xhr.getAllResponseHeaders()), + config + }); + } catch (error) { + reject( + error instanceof HttpClientError + ? error + : new NetworkError(getErrorMessage(error, 'Network request failed'), { + cause: error, + config + }) + ); + } + } else { + let errorData: unknown; + try { + if (xhr.responseType === 'text') { + const text = xhr.responseText; + if (text) { + try { + errorData = JSON.parse(text); + } catch { + errorData = text; + } } } + } catch { + errorData = undefined; } - } catch { - errorData = undefined; + + reject( + new HttpResponseError( + `Request failed with status code ${xhr.status}`, + xhr.status, + xhr.statusText, + parseResponseHeaders(xhr.getAllResponseHeaders()), + config, + errorData + ) + ); } + }; - reject( - new HttpResponseError( - `Request failed with status code ${xhr.status}`, - xhr.status, - xhr.statusText, - parseResponseHeaders(xhr.getAllResponseHeaders()), - config, - errorData - ) - ); - } - }; + xhr.onerror = event => { + rejectOrFailStream(new NetworkError('Network request failed', { cause: event, 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.onabort = event => { - rejectOrFailStream( - new AbortError('Request was aborted', { cause: userSignal?.reason ?? event, config }) - ); - }; + xhr.ontimeout = event => { + rejectOrFailStream( + new TimeoutError(`Request timed out after ${timeout ?? 0}ms`, { cause: event, config }) + ); + }; - xhr.ontimeout = event => { - rejectOrFailStream( - new TimeoutError(`Request timed out after ${timeout ?? 0}ms`, { cause: event, config }) + xhr.send(body); + } catch (error) { + cleanup(); + reject( + error instanceof HttpClientError + ? error + : new RequestPreparationError('Failed to prepare HTTP request', { cause: error, config }) ); - }; - - xhr.send(body); + } }); } } diff --git a/services/http-client/src/asserts/assertValidRequestConfig.ts b/services/http-client/src/asserts/assertValidRequestConfig.ts new file mode 100644 index 00000000..7cae1814 --- /dev/null +++ b/services/http-client/src/asserts/assertValidRequestConfig.ts @@ -0,0 +1,53 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; +import { IHttpRequestConfig } from '../types'; +import { assertBodyAllowed } from './assertBodyAllowed'; +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 { 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, signal, timeout, 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 (responseType !== undefined) { + assertValidResponseType(responseType); + } + + if (Object.prototype.hasOwnProperty.call(config, 'data')) { + assertBodyAllowed(method); + } +} diff --git a/services/http-client/src/asserts/index.ts b/services/http-client/src/asserts/index.ts index 4bad65fc..963a21af 100644 --- a/services/http-client/src/asserts/index.ts +++ b/services/http-client/src/asserts/index.ts @@ -6,6 +6,7 @@ export * from './assertValidHeaders'; export * from './assertValidMethod'; export * from './assertValidParam'; export * from './assertValidParams'; +export * from './assertValidRequestConfig'; export * from './assertValidResponseType'; export * from './assertValidSignal'; export * from './assertValidTimeout'; diff --git a/services/http-client/src/core/HttpClient.ts b/services/http-client/src/core/HttpClient.ts index ca1bd90c..dd24f0d4 100644 --- a/services/http-client/src/core/HttpClient.ts +++ b/services/http-client/src/core/HttpClient.ts @@ -1,4 +1,5 @@ import { HTTP_METHODS } from '../constants'; +import { assertValidRequestConfig } from '../asserts'; import { THttpMethod, IHttpRequestConfig, @@ -118,7 +119,9 @@ export class HttpClient { if (onRequest !== undefined) { try { - mergedConfig = await onRequest(mergedConfig); + const requestConfig = await onRequest(mergedConfig); + assertValidRequestConfig(requestConfig); + mergedConfig = requestConfig; } catch (error) { if (onRequestError === undefined) { throw error; @@ -130,6 +133,7 @@ export class HttpClient { throw error; } + assertValidRequestConfig(recoveredConfig); mergedConfig = recoveredConfig; } } diff --git a/services/http-client/src/core/HttpRequestBuilder.ts b/services/http-client/src/core/HttpRequestBuilder.ts index f8faa12e..ee9cfa62 100644 --- a/services/http-client/src/core/HttpRequestBuilder.ts +++ b/services/http-client/src/core/HttpRequestBuilder.ts @@ -20,6 +20,7 @@ import { assertValidMethod, assertValidParam, assertValidParams, + assertValidRequestConfig, assertValidResponseType, assertValidSignal, assertValidTimeout, @@ -64,36 +65,7 @@ export class HttpRequestBuilder { } public build(): Readonly { - assertValidMethod(this.config.method); - assertValidUrl(this.config.url); - - if (this.config.baseUrl !== undefined) { - assertValidBaseUrl(this.config.baseUrl); - } - - if (this.config.headers !== undefined) { - assertValidHeaders(this.config.headers); - } - - if (this.config.params !== undefined) { - assertValidParams(this.config.params); - } - - if (this.config.signal !== undefined) { - assertValidSignal(this.config.signal); - } - - if (this.config.timeout !== undefined) { - assertValidTimeout(this.config.timeout); - } - - if (this.config.responseType !== undefined) { - assertValidResponseType(this.config.responseType); - } - - if (Object.prototype.hasOwnProperty.call(this.config, 'data')) { - assertBodyAllowed(this.config.method); - } + assertValidRequestConfig(this.config); return cloneConfig(this.config); } 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/index.ts b/services/http-client/src/errors/index.ts index a531250f..ac50a849 100644 --- a/services/http-client/src/errors/index.ts +++ b/services/http-client/src/errors/index.ts @@ -5,4 +5,5 @@ export { HttpResponseError, isHttpResponseError } from './HttpResponseError'; export { NetworkError, isNetworkError } from './NetworkError'; export { ParseError, isParseError } from './ParseError'; export { RequestBuilderError, isRequestBuilderError } from './RequestBuilderError'; +export { RequestPreparationError, isRequestPreparationError } from './RequestPreparationError'; export { TimeoutError, isTimeoutError } from './TimeoutError'; diff --git a/services/http-client/test/core/HttpClient.hooks.tests.ts b/services/http-client/test/core/HttpClient.hooks.tests.ts index 305b95d4..bbacea5b 100644 --- a/services/http-client/test/core/HttpClient.hooks.tests.ts +++ b/services/http-client/test/core/HttpClient.hooks.tests.ts @@ -3,8 +3,14 @@ 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 } from '../../src/constants'; -import { AbortError, HttpResponseError, NetworkError, RequestBuilderError } from '../../src/errors'; +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__'; @@ -65,6 +71,50 @@ describe.each(adapters)('HttpClient.$name — hooks', ({ create }) => { 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').execute<{ id: number }>(); + + expect(response.data?.id).toBe(1); + expect(caught).toMatchObject({ + code: REQUEST_BUILDER_ERROR_CODES.INVALID_METHOD + }); + }); + + 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( @@ -181,6 +231,24 @@ describe.each(adapters)('HttpClient.$name — hooks', ({ create }) => { 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({ @@ -319,3 +387,31 @@ describe.each(adapters)('HttpClient.$name — hooks', ({ create }) => { 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/errors/HttpClientError.tests.ts b/services/http-client/test/errors/HttpClientError.tests.ts index 0a1cc469..28cacf4a 100644 --- a/services/http-client/test/errors/HttpClientError.tests.ts +++ b/services/http-client/test/errors/HttpClientError.tests.ts @@ -9,10 +9,12 @@ import { isNetworkError, isParseError, isRequestBuilderError, + isRequestPreparationError, isTimeoutError, NetworkError, ParseError, RequestBuilderError, + RequestPreparationError, TimeoutError } from '../../src/errors'; import { IHttpRequestConfig } from '../../src/types'; @@ -32,6 +34,7 @@ const errors: Array<{ name: string; create: () => HttpClientError }> = [ 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') } ]; @@ -50,6 +53,11 @@ const errorGuards: Array<{ name: string; guard: (error: unknown) => boolean; cre 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') } ]; @@ -107,4 +115,12 @@ describe('HttpClientError', () => { expect(error.cause).toBe(cause); 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 index 4b78d6b4..830f8fcc 100644 --- a/services/http-client/test/errors/errorGuards.tests-d.ts +++ b/services/http-client/test/errors/errorGuards.tests-d.ts @@ -9,10 +9,12 @@ import { isNetworkError, isParseError, isRequestBuilderError, + isRequestPreparationError, isTimeoutError, NetworkError, ParseError, RequestBuilderError, + RequestPreparationError, TimeoutError } from '../../src/errors'; @@ -47,5 +49,9 @@ describe('error guards', () => { if (isRequestBuilderError(error)) { expectTypeOf(error).toEqualTypeOf(); } + + if (isRequestPreparationError(error)) { + expectTypeOf(error).toEqualTypeOf(); + } }); }); From a5bf1c11e3efb962dfaab7cedf0f66d2a5a04849 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Wed, 19 Aug 2026 22:55:56 +0500 Subject: [PATCH 33/60] feat(http): improve adapters --- .../http-client/src/adapters/FetchAdapter.ts | 18 ++---- .../http-client/src/adapters/XhrAdapter.ts | 22 ++----- .../http-client/src/types/HttpRequestBody.ts | 1 + services/http-client/src/types/index.ts | 1 + services/http-client/src/utilities/index.ts | 1 + .../src/utilities/prepareRequestBody.ts | 42 ++++++++++++++ .../__handlers__/HttpClient.POST.handlers.ts | 25 ++++++++ .../test/core/HttpClient.POST.tests.ts | 57 +++++++++++++++++++ 8 files changed, 137 insertions(+), 30 deletions(-) create mode 100644 services/http-client/src/types/HttpRequestBody.ts create mode 100644 services/http-client/src/utilities/prepareRequestBody.ts diff --git a/services/http-client/src/adapters/FetchAdapter.ts b/services/http-client/src/adapters/FetchAdapter.ts index 84dc908b..e3442cf5 100644 --- a/services/http-client/src/adapters/FetchAdapter.ts +++ b/services/http-client/src/adapters/FetchAdapter.ts @@ -7,7 +7,7 @@ import { RequestPreparationError } from '../errors/RequestPreparationError'; import { TimeoutError } from '../errors/TimeoutError'; import { AbortError } from '../errors/AbortError'; import { IHttpClientAdapter, IHttpRequestConfig, IHttpResponse, THttpHeaders, THttpResponseType } from '../types'; -import { buildUrl, getErrorMessage, hasHeader, mergeHeaders } from '../utilities'; +import { buildUrl, getErrorMessage, mergeHeaders, prepareRequestBody } from '../utilities'; function extractResponseHeaders(headers: Headers): THttpHeaders { const result: THttpHeaders = {}; @@ -42,18 +42,10 @@ function prepareFetchRequest(config: IHttpRequestConfig): IPreparedFetchRequest try { const fullUrl = buildUrl(baseURL, url, params); const requestHeaders = mergeHeaders(headers); - let body: BodyInit | undefined; - - if (data !== undefined && method !== HTTP_METHODS.GET && method !== HTTP_METHODS.HEAD) { - if (typeof data === 'string' || data instanceof ArrayBuffer || data instanceof Blob) { - body = data as BodyInit; - } else { - body = JSON.stringify(data); - if (!hasHeader(requestHeaders, 'Content-Type')) { - requestHeaders['Content-Type'] = 'application/json'; - } - } - } + const body = + data !== undefined && method !== HTTP_METHODS.GET && method !== HTTP_METHODS.HEAD + ? prepareRequestBody(data, requestHeaders) + : undefined; if (timeout) { const controller = new AbortController(); diff --git a/services/http-client/src/adapters/XhrAdapter.ts b/services/http-client/src/adapters/XhrAdapter.ts index 07f44b97..a6246ba9 100644 --- a/services/http-client/src/adapters/XhrAdapter.ts +++ b/services/http-client/src/adapters/XhrAdapter.ts @@ -7,7 +7,7 @@ import { RequestPreparationError } from '../errors/RequestPreparationError'; import { TimeoutError } from '../errors/TimeoutError'; import { AbortError } from '../errors/AbortError'; import { IHttpClientAdapter, IHttpRequestConfig, IHttpResponse, THttpHeaders, THttpResponseType } from '../types'; -import { buildUrl, getErrorMessage, hasHeader, mergeHeaders } from '../utilities'; +import { buildUrl, getErrorMessage, mergeHeaders, prepareRequestBody } from '../utilities'; function parseResponseHeaders(rawHeaders: string): THttpHeaders { const result: THttpHeaders = {}; @@ -182,26 +182,14 @@ export class XhrAdapter implements IHttpClientAdapter { } const requestHeaders = mergeHeaders(headers); - if (data !== undefined && method !== HTTP_METHODS.GET && method !== HTTP_METHODS.HEAD) { - if (typeof data !== 'string' && !(data instanceof ArrayBuffer) && !(data instanceof Blob)) { - if (!hasHeader(requestHeaders, 'Content-Type')) { - requestHeaders['Content-Type'] = 'application/json'; - } - } - } + 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); } - let body: string | ArrayBuffer | Blob | undefined; - if (data !== undefined && method !== HTTP_METHODS.GET && method !== HTTP_METHODS.HEAD) { - if (typeof data === 'string' || data instanceof ArrayBuffer || data instanceof Blob) { - body = data; - } else { - body = JSON.stringify(data); - } - } - const onAbort = (): void => xhr.abort(); if (userSignal) { userSignal.addEventListener('abort', onAbort, { once: true }); 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/index.ts b/services/http-client/src/types/index.ts index 66eb779f..d0a8447d 100644 --- a/services/http-client/src/types/index.ts +++ b/services/http-client/src/types/index.ts @@ -3,6 +3,7 @@ export * from './HttpStatusCode'; export * from './HttpClientAdapter'; export * from './HttpClientOptions'; export * from './HttpRequestConfig'; +export * from './HttpRequestBody'; export * from './HttpRequestExecutor'; export * from './HttpResponse'; export * from './HttpHeaders'; diff --git a/services/http-client/src/utilities/index.ts b/services/http-client/src/utilities/index.ts index 8abeae88..87fff5ab 100644 --- a/services/http-client/src/utilities/index.ts +++ b/services/http-client/src/utilities/index.ts @@ -2,3 +2,4 @@ export { buildUrl } from './buildUrl'; export { getErrorMessage } from './getErrorMessage'; export { hasHeader, mergeHeaders } from './mergeHeaders'; export { mergeParams } from './mergeParams'; +export { prepareRequestBody } from './prepareRequestBody'; diff --git a/services/http-client/src/utilities/prepareRequestBody.ts b/services/http-client/src/utilities/prepareRequestBody.ts new file mode 100644 index 00000000..86dd36f7 --- /dev/null +++ b/services/http-client/src/utilities/prepareRequestBody.ts @@ -0,0 +1,42 @@ +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]'); +} + +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/__handlers__/HttpClient.POST.handlers.ts b/services/http-client/test/__handlers__/HttpClient.POST.handlers.ts index 4cd05c2e..6d1fb52f 100644 --- a/services/http-client/test/__handlers__/HttpClient.POST.handlers.ts +++ b/services/http-client/test/__handlers__/HttpClient.POST.handlers.ts @@ -14,6 +14,31 @@ export const handlers = [ 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/core/HttpClient.POST.tests.ts b/services/http-client/test/core/HttpClient.POST.tests.ts index 11280c6e..892a9ab3 100644 --- a/services/http-client/test/core/HttpClient.POST.tests.ts +++ b/services/http-client/test/core/HttpClient.POST.tests.ts @@ -1,4 +1,5 @@ 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'; @@ -50,6 +51,62 @@ describe.each(adapters)('HttpClient.$name — POST', ({ create }) => { expect(response.data?.contentType).toBe('text/plain'); }); + 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) + .execute<{ name: string; roles: string[]; contentType: string | null }>(); + + 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) + .execute<{ name: string; roles: string[]; contentType: string | null }>(); + + 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])])) + .execute<{ received: number[] }>(); + + 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])) + .execute<{ received: number[] }>(); + + 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(), From 687189ccd5393e56f92b5445d41d0ded42fb6d04 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Wed, 19 Aug 2026 23:11:54 +0500 Subject: [PATCH 34/60] feat(http): add assert --- .../asserts/assertValidHttpClientOptions.ts | 71 ++++++++++++++ services/http-client/src/asserts/index.ts | 1 + .../src/constants/requestBuilderErrorCodes.ts | 2 + services/http-client/src/core/HttpClient.ts | 4 +- .../test/core/HttpClient.OPTIONS.tests.ts | 2 +- .../test/core/HttpClient.constructor.tests.ts | 97 +++++++++++++++++++ 6 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 services/http-client/src/asserts/assertValidHttpClientOptions.ts create mode 100644 services/http-client/test/core/HttpClient.constructor.tests.ts diff --git a/services/http-client/src/asserts/assertValidHttpClientOptions.ts b/services/http-client/src/asserts/assertValidHttpClientOptions.ts new file mode 100644 index 00000000..58ec5347 --- /dev/null +++ b/services/http-client/src/asserts/assertValidHttpClientOptions.ts @@ -0,0 +1,71 @@ +import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { RequestBuilderError } from '../errors'; +import { IHttpClientAdapter, IHttpClientOptions } from '../types'; +import { assertValidBaseUrl } from './assertValidBaseUrl'; +import { assertValidHeaders } from './assertValidHeaders'; +import { assertValidParams } from './assertValidParams'; +import { assertValidTimeout } from './assertValidTimeout'; +import { isRecord } from './isRecord'; + +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 + ); + } +} + +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, onRequest, onRequestError, onResponse, onResponseError } = + options; + + assertValidAdapter(adapter); + + if (baseUrl !== undefined) { + assertValidBaseUrl(baseUrl); + } + + if (headers !== undefined) { + assertValidHeaders(headers); + } + + if (params !== undefined) { + assertValidParams(params); + } + + if (timeout !== undefined) { + assertValidTimeout(timeout); + } + + 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/index.ts b/services/http-client/src/asserts/index.ts index 963a21af..8a83b261 100644 --- a/services/http-client/src/asserts/index.ts +++ b/services/http-client/src/asserts/index.ts @@ -3,6 +3,7 @@ export * from './assertNonBlankString'; export * from './assertValidBaseUrl'; export * from './assertValidHeader'; export * from './assertValidHeaders'; +export * from './assertValidHttpClientOptions'; export * from './assertValidMethod'; export * from './assertValidParam'; export * from './assertValidParams'; diff --git a/services/http-client/src/constants/requestBuilderErrorCodes.ts b/services/http-client/src/constants/requestBuilderErrorCodes.ts index 72d09a42..505f3eb1 100644 --- a/services/http-client/src/constants/requestBuilderErrorCodes.ts +++ b/services/http-client/src/constants/requestBuilderErrorCodes.ts @@ -1,11 +1,13 @@ 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_HEADER: 'INVALID_HEADER', INVALID_HEADERS: 'INVALID_HEADERS', + INVALID_HOOK: 'INVALID_HOOK', INVALID_METHOD: 'INVALID_METHOD', INVALID_PARAM: 'INVALID_PARAM', INVALID_PARAMS: 'INVALID_PARAMS', diff --git a/services/http-client/src/core/HttpClient.ts b/services/http-client/src/core/HttpClient.ts index dd24f0d4..687476f6 100644 --- a/services/http-client/src/core/HttpClient.ts +++ b/services/http-client/src/core/HttpClient.ts @@ -1,5 +1,5 @@ import { HTTP_METHODS } from '../constants'; -import { assertValidRequestConfig } from '../asserts'; +import { assertValidHttpClientOptions, assertValidRequestConfig } from '../asserts'; import { THttpMethod, IHttpRequestConfig, @@ -24,6 +24,8 @@ export class HttpClient { private onResponseErrorHook?: THttpResponseErrorHook; constructor(options: IHttpClientOptions) { + assertValidHttpClientOptions(options); + this.adapter = options.adapter; this.onRequestHook = options.onRequest; this.onRequestErrorHook = options.onRequestError; diff --git a/services/http-client/test/core/HttpClient.OPTIONS.tests.ts b/services/http-client/test/core/HttpClient.OPTIONS.tests.ts index 97e2ebd1..caacc6dc 100644 --- a/services/http-client/test/core/HttpClient.OPTIONS.tests.ts +++ b/services/http-client/test/core/HttpClient.OPTIONS.tests.ts @@ -35,4 +35,4 @@ describe.each(adapters)('HttpClient.$name — OPTIONS', ({ create }) => { expect(response.status).toBe(HTTP_STATUS_CODES.NO_CONTENT); expect(response.headers['allow']).toBe('GET, POST, HEAD, OPTIONS'); }); -}); \ No newline at end of file +}); 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..2d9080e3 --- /dev/null +++ b/services/http-client/test/core/HttpClient.constructor.tests.ts @@ -0,0 +1,97 @@ +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: 1 } }, + code: REQUEST_BUILDER_ERROR_CODES.INVALID_PARAM + }, + { + name: 'timeout', + options: { ...createOptions(), timeout: -1 }, + code: REQUEST_BUILDER_ERROR_CODES.INVALID_TIMEOUT + }, + { + 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, + onRequest: config => config, + onRequestError: () => undefined, + onResponse: response => response, + onResponseError: () => undefined + }; + + expect(() => new HttpClient(options)).not.toThrow(); + }); +}); From 3a5568c04e2ce442f339f1146ba6fba960637df5 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Thu, 20 Aug 2026 00:10:34 +0500 Subject: [PATCH 35/60] feat(http): ref --- .../http-client/src/adapters/XhrAdapter.ts | 400 +++++++++--------- .../src/asserts/assertValidParam.ts | 10 +- services/http-client/src/types/HttpParams.ts | 4 +- .../http-client/src/utilities/buildUrl.ts | 2 +- .../__handlers__/HttpClient.GET.handlers.ts | 2 + .../test/core/HttpClient.GET.tests.ts | 14 + .../test/core/HttpClient.constructor.tests.ts | 2 +- .../test/core/HttpRequestBuilder.tests-d.ts | 11 +- .../test/core/HttpRequestBuilder.tests.ts | 2 +- 9 files changed, 247 insertions(+), 200 deletions(-) diff --git a/services/http-client/src/adapters/XhrAdapter.ts b/services/http-client/src/adapters/XhrAdapter.ts index a6246ba9..d20b099c 100644 --- a/services/http-client/src/adapters/XhrAdapter.ts +++ b/services/http-client/src/adapters/XhrAdapter.ts @@ -125,205 +125,219 @@ async function getResponseBody( } } +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 принимает только DOM-значение в нижнем регистре + xhr.responseType = 'arraybuffer'; + } else if (responseType === HTTP_RESPONSE_TYPES.BLOB || responseType === HTTP_RESPONSE_TYPES.FORM_DATA) { + 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 + }; +} + +function getErrorData(xhr: XMLHttpRequest): unknown { + try { + if (xhr.responseType !== 'text') { + return undefined; + } + + const text = xhr.responseText; + if (!text) { + return undefined; + } + + try { + return JSON.parse(text); + } catch { + return text; + } + } catch { + return undefined; + } +} + +function createResponseError(xhr: XMLHttpRequest, config: IHttpRequestConfig): HttpResponseError { + return new HttpResponseError( + `Request failed with status code ${xhr.status}`, + xhr.status, + xhr.statusText, + parseResponseHeaders(xhr.getAllResponseHeaders()), + config, + getErrorData(xhr) + ); +} + +function prepareXhrRequest(config: IHttpRequestConfig): 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); + + 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, + resolve: (value: IHttpResponse | PromiseLike>) => void, + reject: (reason?: unknown) => void +): void { + const { xhr } = request; + const { signal: userSignal, timeout, responseType } = config; + let responseStream: IXhrResponseStream | undefined; + let streamResponseResolved = false; + const isSuccessful = (): boolean => xhr.status >= 200 && xhr.status < 300; + 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) { + resolveStream(); + } + }; + + xhr.onprogress = () => { + responseStream?.append(); + }; + } + + xhr.onload = async () => { + request.cleanup(); + + if (!isSuccessful()) { + reject(createResponseError(xhr, config)); + return; + } + + if (responseType === HTTP_RESPONSE_TYPES.STREAM) { + resolveStream(); + responseStream?.append(); + responseStream?.close(); + + return; + } + + try { + const responseData = await 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 => { + rejectOrFailStream(new TimeoutError(`Request timed out after ${timeout ?? 0}ms`, { cause: event, config })); + }; +} + export class XhrAdapter implements IHttpClientAdapter { request(config: IHttpRequestConfig): Promise> { - const { - url, - method, - headers = {}, - params, - data, - signal: userSignal, - timeout, - responseType, - baseUrl: baseURL - } = config; - return new Promise>((resolve, reject) => { - let cleanup: () => void = () => undefined; + let request: IPreparedXhrRequest | undefined; try { - const fullUrl = buildUrl(baseURL, url, params); - - if (userSignal?.aborted) { - reject(new AbortError('Request was aborted', { cause: userSignal.reason, config })); - return; - } - - if ( - responseType === HTTP_RESPONSE_TYPES.STREAM && - (typeof ReadableStream === 'undefined' || typeof TextEncoder === 'undefined') - ) { - reject( - new RequestPreparationError('Streaming responses are not supported in this environment', { - config - }) - ); - return; - } - - const xhr = new XMLHttpRequest(); - xhr.open(method, fullUrl, true); - - if (timeout !== undefined && timeout > 0) { - xhr.timeout = timeout; - } - - if (responseType === HTTP_RESPONSE_TYPES.ARRAY_BUFFER) { - // XHR принимает только DOM-значение в нижнем регистре - xhr.responseType = 'arraybuffer'; - } else if ( - responseType === HTTP_RESPONSE_TYPES.BLOB || - responseType === HTTP_RESPONSE_TYPES.FORM_DATA - ) { - xhr.responseType = 'blob'; - } else { - xhr.responseType = 'text'; - } - - 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(); - if (userSignal) { - userSignal.addEventListener('abort', onAbort, { once: true }); - } - - cleanup = (): void => { - if (userSignal) { - userSignal.removeEventListener('abort', onAbort); - } - }; - - let responseStream: IXhrResponseStream | undefined; - let streamResponseResolved = false; - const isSuccessful = (): boolean => xhr.status >= 200 && xhr.status < 300; - const resolveStream = (): void => { - if (streamResponseResolved || !isSuccessful()) { - return; - } - - responseStream = createResponseStream(xhr); - streamResponseResolved = true; - resolve({ - data: responseStream.data as T, - status: xhr.status, - statusText: xhr.statusText, - headers: parseResponseHeaders(xhr.getAllResponseHeaders()), - config - }); - }; - - if (responseType === HTTP_RESPONSE_TYPES.STREAM) { - xhr.onreadystatechange = () => { - if (xhr.readyState === XMLHttpRequest.HEADERS_RECEIVED) { - resolveStream(); - } - }; - - xhr.onprogress = () => { - responseStream?.append(); - }; - } - - const rejectOrFailStream = (error: HttpClientError): void => { - cleanup(); - - if (streamResponseResolved) { - responseStream?.error(error); - return; - } - - reject(error); - }; - - xhr.onload = async () => { - cleanup(); - - if (isSuccessful()) { - if (responseType === HTTP_RESPONSE_TYPES.STREAM) { - resolveStream(); - responseStream?.append(); - responseStream?.close(); - - return; - } - - try { - const responseData = await getResponseBody(xhr, config, responseType); - - resolve({ - data: responseData, - status: xhr.status, - statusText: xhr.statusText, - headers: parseResponseHeaders(xhr.getAllResponseHeaders()), - config - }); - } catch (error) { - reject( - error instanceof HttpClientError - ? error - : new NetworkError(getErrorMessage(error, 'Network request failed'), { - cause: error, - config - }) - ); - } - } else { - let errorData: unknown; - try { - if (xhr.responseType === 'text') { - const text = xhr.responseText; - if (text) { - try { - errorData = JSON.parse(text); - } catch { - errorData = text; - } - } - } - } catch { - errorData = undefined; - } - - reject( - new HttpResponseError( - `Request failed with status code ${xhr.status}`, - xhr.status, - xhr.statusText, - parseResponseHeaders(xhr.getAllResponseHeaders()), - config, - errorData - ) - ); - } - }; - - 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 => { - rejectOrFailStream( - new TimeoutError(`Request timed out after ${timeout ?? 0}ms`, { cause: event, config }) - ); - }; - - xhr.send(body); + request = prepareXhrRequest(config); + configureXhrEventHandlers(request, config, resolve, reject); + request.xhr.send(request.body); } catch (error) { - cleanup(); + request?.cleanup(); reject( error instanceof HttpClientError ? error diff --git a/services/http-client/src/asserts/assertValidParam.ts b/services/http-client/src/asserts/assertValidParam.ts index 4e488554..9081560f 100644 --- a/services/http-client/src/asserts/assertValidParam.ts +++ b/services/http-client/src/asserts/assertValidParam.ts @@ -3,12 +3,18 @@ import { RequestBuilderError } from '../errors'; import { THttpParamValue } from '../types'; import { assertNonBlankString } from './assertNonBlankString'; +function isValidParamValue(value: unknown): value is string | number | boolean { + return ( + typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value)) + ); +} + 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 (typeof value !== 'string' && !(Array.isArray(value) && value.every(item => typeof item === 'string'))) { + if (!isValidParamValue(value) && !(Array.isArray(value) && value.every(isValidParamValue))) { throw new RequestBuilderError( - 'Param value must be a string or an array of strings', + 'Param value must be a string, finite number, boolean, or an array of these values', REQUEST_BUILDER_ERROR_CODES.INVALID_PARAM ); } diff --git a/services/http-client/src/types/HttpParams.ts b/services/http-client/src/types/HttpParams.ts index 5754ec55..3a7bb2f2 100644 --- a/services/http-client/src/types/HttpParams.ts +++ b/services/http-client/src/types/HttpParams.ts @@ -1,3 +1,5 @@ -export type THttpParamValue = string | Array; +export type THttpParamPrimitive = string | number | boolean; + +export type THttpParamValue = THttpParamPrimitive | Array; export type THttpParams = Record; diff --git a/services/http-client/src/utilities/buildUrl.ts b/services/http-client/src/utilities/buildUrl.ts index 5b29040c..555cf851 100644 --- a/services/http-client/src/utilities/buildUrl.ts +++ b/services/http-client/src/utilities/buildUrl.ts @@ -50,7 +50,7 @@ function serializeParams(params: THttpParams | undefined): string { const values = Array.isArray(value) ? value : [value]; for (const item of values) { - searchParams.append(key, item); + searchParams.append(key, String(item)); } } diff --git a/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts index 00e309d3..33589035 100644 --- a/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts +++ b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts @@ -11,6 +11,8 @@ export const handlers = [ 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') }); }), diff --git a/services/http-client/test/core/HttpClient.GET.tests.ts b/services/http-client/test/core/HttpClient.GET.tests.ts index ada4bf45..a1fb8f7d 100644 --- a/services/http-client/test/core/HttpClient.GET.tests.ts +++ b/services/http-client/test/core/HttpClient.GET.tests.ts @@ -49,6 +49,20 @@ describe.each(adapters)('HttpClient.$name — GET', ({ create }) => { 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'] }) + .execute<{ page: string | null; active: string | null; value: string[] }>(); + + expect(response.data).toMatchObject({ + page: '2', + active: 'true', + value: ['0', 'false', 'all'] + }); + }); + test('preserves an existing query and appends params before a fragment', async () => { const client = createClient(); const response = await client diff --git a/services/http-client/test/core/HttpClient.constructor.tests.ts b/services/http-client/test/core/HttpClient.constructor.tests.ts index 2d9080e3..a2a1e5cd 100644 --- a/services/http-client/test/core/HttpClient.constructor.tests.ts +++ b/services/http-client/test/core/HttpClient.constructor.tests.ts @@ -47,7 +47,7 @@ describe('HttpClient constructor', () => { }, { name: 'params', - options: { ...createOptions(), params: { page: 1 } }, + options: { ...createOptions(), params: { page: Number.NaN } }, code: REQUEST_BUILDER_ERROR_CODES.INVALID_PARAM }, { diff --git a/services/http-client/test/core/HttpRequestBuilder.tests-d.ts b/services/http-client/test/core/HttpRequestBuilder.tests-d.ts index a0bf8738..5e16dc48 100644 --- a/services/http-client/test/core/HttpRequestBuilder.tests-d.ts +++ b/services/http-client/test/core/HttpRequestBuilder.tests-d.ts @@ -1,7 +1,7 @@ import { describe, expectTypeOf, it } from 'vitest'; import { HTTP_METHODS, HTTP_STATUS_CODES } from '../../src/constants'; import { HttpRequestBuilder } from '../../src/core/HttpRequestBuilder'; -import { IHttpRequestConfig, IHttpResponse, THttpRequestExecutor } from '../../src/types'; +import { IHttpRequestConfig, IHttpResponse, THttpParams, THttpRequestExecutor } from '../../src/types'; const executor: THttpRequestExecutor = (config: IHttpRequestConfig): Promise> => Promise.resolve({ @@ -31,4 +31,13 @@ describe('HttpRequestBuilder types', () => { expectTypeOf(headBuilder.body({ value: true })).toEqualTypeOf(); expectTypeOf(postBuilder.body({ value: true })).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 index e5028adc..33193bc8 100644 --- a/services/http-client/test/core/HttpRequestBuilder.tests.ts +++ b/services/http-client/test/core/HttpRequestBuilder.tests.ts @@ -179,7 +179,7 @@ describe('HttpRequestBuilder', () => { }, { name: 'param value', - action: (builder: HttpRequestBuilder) => builder.param('page', 1 as unknown as string), + action: (builder: HttpRequestBuilder) => builder.param('page', Number.NaN), code: REQUEST_BUILDER_ERROR_CODES.INVALID_PARAM }, { From 19b3aa82271af35064edb591a955e7c29b0afb3c Mon Sep 17 00:00:00 2001 From: abrosimov Date: Thu, 20 Aug 2026 14:00:56 +0500 Subject: [PATCH 36/60] feat(http): fetch is default adapter --- .../asserts/assertValidHttpClientOptions.ts | 4 ++- services/http-client/src/core/HttpClient.ts | 3 ++- .../src/types/HttpClientOptions.ts | 3 ++- .../test/core/HttpClient.constructor.tests.ts | 25 +++++++++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/services/http-client/src/asserts/assertValidHttpClientOptions.ts b/services/http-client/src/asserts/assertValidHttpClientOptions.ts index 58ec5347..3590fdc5 100644 --- a/services/http-client/src/asserts/assertValidHttpClientOptions.ts +++ b/services/http-client/src/asserts/assertValidHttpClientOptions.ts @@ -35,7 +35,9 @@ export function assertValidHttpClientOptions(options: unknown): asserts options const { adapter, baseUrl, headers, params, timeout, onRequest, onRequestError, onResponse, onResponseError } = options; - assertValidAdapter(adapter); + if (adapter !== undefined) { + assertValidAdapter(adapter); + } if (baseUrl !== undefined) { assertValidBaseUrl(baseUrl); diff --git a/services/http-client/src/core/HttpClient.ts b/services/http-client/src/core/HttpClient.ts index 687476f6..00825156 100644 --- a/services/http-client/src/core/HttpClient.ts +++ b/services/http-client/src/core/HttpClient.ts @@ -1,5 +1,6 @@ import { HTTP_METHODS } from '../constants'; import { assertValidHttpClientOptions, assertValidRequestConfig } from '../asserts'; +import { FetchAdapter } from '../adapters'; import { THttpMethod, IHttpRequestConfig, @@ -26,7 +27,7 @@ export class HttpClient { constructor(options: IHttpClientOptions) { assertValidHttpClientOptions(options); - this.adapter = options.adapter; + this.adapter = options.adapter ?? new FetchAdapter(); this.onRequestHook = options.onRequest; this.onRequestErrorHook = options.onRequestError; this.onResponseHook = options.onResponse; diff --git a/services/http-client/src/types/HttpClientOptions.ts b/services/http-client/src/types/HttpClientOptions.ts index 11301251..39e3ee30 100644 --- a/services/http-client/src/types/HttpClientOptions.ts +++ b/services/http-client/src/types/HttpClientOptions.ts @@ -4,7 +4,8 @@ import { THttpRequestErrorHook, THttpRequestHook, THttpResponseErrorHook, THttpR import { THttpParams } from './HttpParams'; export interface IHttpClientOptions { - adapter: IHttpClientAdapter; + /** Uses FetchAdapter when omitted. */ + adapter?: IHttpClientAdapter; baseUrl?: string; headers?: THttpHeaders; params?: THttpParams; diff --git a/services/http-client/test/core/HttpClient.constructor.tests.ts b/services/http-client/test/core/HttpClient.constructor.tests.ts index a2a1e5cd..655e19a7 100644 --- a/services/http-client/test/core/HttpClient.constructor.tests.ts +++ b/services/http-client/test/core/HttpClient.constructor.tests.ts @@ -94,4 +94,29 @@ describe('HttpClient constructor', () => { 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').execute<{ ok: boolean }>(); + + expect(response.data).toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://example.test/health', + expect.objectContaining({ method: 'GET' }) + ); + } finally { + vi.unstubAllGlobals(); + } + }); }); From dcbfe03af672fe6e88bf2c490fc7001fe0eade2e Mon Sep 17 00:00:00 2001 From: abrosimov Date: Fri, 21 Aug 2026 10:06:00 +0500 Subject: [PATCH 37/60] feat(http): serialize params --- .../src/asserts/assertValidParam.ts | 16 ++++++++--- services/http-client/src/types/HttpParams.ts | 7 ++++- .../http-client/src/utilities/buildUrl.ts | 8 ++++++ .../http-client/src/utilities/mergeParams.ts | 27 ++++++++++++++++--- .../__handlers__/HttpClient.GET.handlers.ts | 3 ++- .../test/core/HttpClient.GET.tests.ts | 16 +++++++++++ .../test/core/HttpRequestBuilder.tests.ts | 14 ++++++++++ 7 files changed, 82 insertions(+), 9 deletions(-) diff --git a/services/http-client/src/asserts/assertValidParam.ts b/services/http-client/src/asserts/assertValidParam.ts index 9081560f..998d5bbd 100644 --- a/services/http-client/src/asserts/assertValidParam.ts +++ b/services/http-client/src/asserts/assertValidParam.ts @@ -1,20 +1,28 @@ import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; import { RequestBuilderError } from '../errors'; -import { THttpParamValue } from '../types'; +import { THttpParamPrimitive, THttpParamValue } from '../types'; import { assertNonBlankString } from './assertNonBlankString'; -function isValidParamValue(value: unknown): value is string | number | boolean { +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) && !(Array.isArray(value) && value.every(isValidParamValue))) { + if (!isValidParamValue(value)) { throw new RequestBuilderError( - 'Param value must be a string, finite number, boolean, or an array of these values', + '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/types/HttpParams.ts b/services/http-client/src/types/HttpParams.ts index 3a7bb2f2..4f23b4a2 100644 --- a/services/http-client/src/types/HttpParams.ts +++ b/services/http-client/src/types/HttpParams.ts @@ -1,5 +1,10 @@ export type THttpParamPrimitive = string | number | boolean; -export type THttpParamValue = THttpParamPrimitive | Array; +/** 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/utilities/buildUrl.ts b/services/http-client/src/utilities/buildUrl.ts index 555cf851..2f22535a 100644 --- a/services/http-client/src/utilities/buildUrl.ts +++ b/services/http-client/src/utilities/buildUrl.ts @@ -47,9 +47,17 @@ function serializeParams(params: THttpParams | undefined): string { 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)); } } diff --git a/services/http-client/src/utilities/mergeParams.ts b/services/http-client/src/utilities/mergeParams.ts index 2350110c..8963644d 100644 --- a/services/http-client/src/utilities/mergeParams.ts +++ b/services/http-client/src/utilities/mergeParams.ts @@ -1,6 +1,10 @@ -import { THttpParams } from '../types'; +import { THttpParamPrimitive, THttpParams } from '../types'; -/** Merges query params from left to right and copies array values. */ +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. */ export function mergeParams(...sources: Array): THttpParams { const result: THttpParams = {}; @@ -10,7 +14,24 @@ export function mergeParams(...sources: Array): THttpPa } for (const [key, value] of Object.entries(source)) { - result[key] = Array.isArray(value) ? [...value] : value; + 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; } } diff --git a/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts index 33589035..273d450f 100644 --- a/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts +++ b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts @@ -13,7 +13,8 @@ export const handlers = [ role: url.searchParams.getAll('role'), active: url.searchParams.get('active'), value: url.searchParams.getAll('value'), - source: url.searchParams.get('source') + source: url.searchParams.get('source'), + keys: [...url.searchParams.keys()] }); }), diff --git a/services/http-client/test/core/HttpClient.GET.tests.ts b/services/http-client/test/core/HttpClient.GET.tests.ts index a1fb8f7d..a8b31c12 100644 --- a/services/http-client/test/core/HttpClient.GET.tests.ts +++ b/services/http-client/test/core/HttpClient.GET.tests.ts @@ -63,6 +63,22 @@ describe.each(adapters)('HttpClient.$name — GET', ({ create }) => { }); }); + 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] }) + .execute<{ page: string | null; active: string | null; source: string | null; value: string[]; keys: string[] }>(); + + 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 diff --git a/services/http-client/test/core/HttpRequestBuilder.tests.ts b/services/http-client/test/core/HttpRequestBuilder.tests.ts index 33193bc8..0a115a9b 100644 --- a/services/http-client/test/core/HttpRequestBuilder.tests.ts +++ b/services/http-client/test/core/HttpRequestBuilder.tests.ts @@ -182,6 +182,11 @@ describe('HttpRequestBuilder', () => { 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), @@ -225,6 +230,15 @@ describe('HttpRequestBuilder', () => { expect(builder.body({ hard: true }).build().data).toEqual({ hard: true }); }); + 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('keeps the default error code for backwards-compatible construction', () => { expect(new RequestBuilderError('Invalid config').code).toBe(REQUEST_BUILDER_ERROR_CODES.INVALID_CONFIG); }); From e8478ee7d4856f6d3e1b32423f6a18f188dfcbcd Mon Sep 17 00:00:00 2001 From: abrosimov Date: Fri, 21 Aug 2026 10:11:34 +0500 Subject: [PATCH 38/60] feat(http): improve errors --- .../http-client/src/adapters/FetchAdapter.ts | 20 +++++++++++++--- .../http-client/src/adapters/XhrAdapter.ts | 24 ++++++++++++++++--- services/http-client/src/errors/ParseError.ts | 20 ++++++++++++++-- .../http-client/src/errors/TimeoutError.ts | 17 +++++++++++-- services/http-client/src/errors/index.ts | 2 ++ .../test/core/HttpClient.GET.tests.ts | 2 ++ .../core/HttpClient.abort-timeout.tests.ts | 1 + .../test/errors/HttpClientError.tests.ts | 18 ++++++++++++-- .../test/errors/errorGuards.tests-d.ts | 4 ++++ 9 files changed, 96 insertions(+), 12 deletions(-) diff --git a/services/http-client/src/adapters/FetchAdapter.ts b/services/http-client/src/adapters/FetchAdapter.ts index e3442cf5..874ffa67 100644 --- a/services/http-client/src/adapters/FetchAdapter.ts +++ b/services/http-client/src/adapters/FetchAdapter.ts @@ -107,12 +107,17 @@ async function parseResponseBody( try { return (await response.formData()) as T; } catch (error) { - throw new ParseError('Failed to parse response body as FormData', { cause: error, config }); + throw new ParseError('Failed to parse response body as FormData', { + cause: error, + config, + responseType: HTTP_RESPONSE_TYPES.FORM_DATA + }); } case 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; @@ -120,7 +125,12 @@ async function parseResponseBody( try { return JSON.parse(text) as T; } catch (error) { - throw new ParseError('Failed to parse response body as JSON', { cause: error, config }); + throw new ParseError('Failed to parse response body as JSON', { + cause: error, + config, + responseType: resolvedResponseType, + raw: text + }); } } } @@ -190,7 +200,11 @@ export class FetchAdapter implements IHttpClientAdapter { } if (request.timeout && error instanceof DOMException && error.name === 'AbortError') { - throw new TimeoutError(`Request timed out after ${request.timeout}ms`, { cause: error, config }); + throw new TimeoutError(`Request timed out after ${request.timeout}ms`, { + cause: error, + config, + timeout: request.timeout + }); } throw new NetworkError(getErrorMessage(error, 'Network request failed'), { cause: error, config }); diff --git a/services/http-client/src/adapters/XhrAdapter.ts b/services/http-client/src/adapters/XhrAdapter.ts index d20b099c..ecbb296d 100644 --- a/services/http-client/src/adapters/XhrAdapter.ts +++ b/services/http-client/src/adapters/XhrAdapter.ts @@ -108,10 +108,15 @@ async function getResponseBody( return (await response.formData()) as T; } catch (error) { - throw new ParseError('Failed to parse response body as FormData', { cause: error, config }); + throw new ParseError('Failed to parse response body as FormData', { + cause: error, + config, + responseType: HTTP_RESPONSE_TYPES.FORM_DATA + }); } 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; @@ -119,7 +124,12 @@ async function getResponseBody( try { return JSON.parse(text) as T; } catch (error) { - throw new ParseError('Failed to parse response body as JSON', { cause: error, config }); + throw new ParseError('Failed to parse response body as JSON', { + cause: error, + config, + responseType: resolvedResponseType, + raw: text + }); } } } @@ -323,7 +333,15 @@ function configureXhrEventHandlers( }; xhr.ontimeout = event => { - rejectOrFailStream(new TimeoutError(`Request timed out after ${timeout ?? 0}ms`, { cause: event, config })); + const timeoutMs = timeout ?? 0; + + rejectOrFailStream( + new TimeoutError(`Request timed out after ${timeoutMs}ms`, { + cause: event, + config, + timeout: timeoutMs + }) + ); }; } diff --git a/services/http-client/src/errors/ParseError.ts b/services/http-client/src/errors/ParseError.ts index 5ca539a8..111942d2 100644 --- a/services/http-client/src/errors/ParseError.ts +++ b/services/http-client/src/errors/ParseError.ts @@ -1,7 +1,23 @@ -import { HttpClientError } from './HttpClientError'; +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 {} +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/TimeoutError.ts b/services/http-client/src/errors/TimeoutError.ts index 309efa56..cf04e8d1 100644 --- a/services/http-client/src/errors/TimeoutError.ts +++ b/services/http-client/src/errors/TimeoutError.ts @@ -1,6 +1,19 @@ -import { HttpClientError } from './HttpClientError'; +import { HttpClientError, IHttpClientErrorOptions } from './HttpClientError'; -export class TimeoutError extends 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 index ac50a849..b7ab4235 100644 --- a/services/http-client/src/errors/index.ts +++ b/services/http-client/src/errors/index.ts @@ -4,6 +4,8 @@ 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/test/core/HttpClient.GET.tests.ts b/services/http-client/test/core/HttpClient.GET.tests.ts index a8b31c12..8482deab 100644 --- a/services/http-client/test/core/HttpClient.GET.tests.ts +++ b/services/http-client/test/core/HttpClient.GET.tests.ts @@ -191,6 +191,8 @@ describe.each(adapters)('HttpClient.$name — GET', ({ create }) => { 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.abort-timeout.tests.ts b/services/http-client/test/core/HttpClient.abort-timeout.tests.ts index 6589b2f4..06b1a2aa 100644 --- a/services/http-client/test/core/HttpClient.abort-timeout.tests.ts +++ b/services/http-client/test/core/HttpClient.abort-timeout.tests.ts @@ -139,6 +139,7 @@ describe.each(adapters)('HttpClient.$name — timeout', ({ name, create }) => { } 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/errors/HttpClientError.tests.ts b/services/http-client/test/errors/HttpClientError.tests.ts index 28cacf4a..58b8820a 100644 --- a/services/http-client/test/errors/HttpClientError.tests.ts +++ b/services/http-client/test/errors/HttpClientError.tests.ts @@ -108,12 +108,26 @@ describe('HttpClientError', () => { expect(new RequestBuilderError('invalid').code).toBe(REQUEST_BUILDER_ERROR_CODES.INVALID_CONFIG); }); - it('preserves the cause of a ParseError', () => { + it('preserves ParseError fields', () => { const cause = new SyntaxError('Unexpected token'); - const error = new ParseError('Failed to parse response body as JSON', { cause, config }); + 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', () => { diff --git a/services/http-client/test/errors/errorGuards.tests-d.ts b/services/http-client/test/errors/errorGuards.tests-d.ts index 830f8fcc..0426b05e 100644 --- a/services/http-client/test/errors/errorGuards.tests-d.ts +++ b/services/http-client/test/errors/errorGuards.tests-d.ts @@ -17,6 +17,7 @@ import { RequestPreparationError, TimeoutError } from '../../src/errors'; +import { THttpResponseType } from '../../src/types'; describe('error guards', () => { it('narrow unknown errors to their concrete types', () => { @@ -36,6 +37,7 @@ describe('error guards', () => { if (isTimeoutError(error)) { expectTypeOf(error).toEqualTypeOf(); + expectTypeOf(error.timeout).toEqualTypeOf(); } if (isAbortError(error)) { @@ -44,6 +46,8 @@ describe('error guards', () => { if (isParseError(error)) { expectTypeOf(error).toEqualTypeOf(); + expectTypeOf(error.responseType).toEqualTypeOf(); + expectTypeOf(error.raw).toEqualTypeOf(); } if (isRequestBuilderError(error)) { From 1a05919df105dbb513ebcee4216a79a7eac29676 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Fri, 21 Aug 2026 10:55:34 +0500 Subject: [PATCH 39/60] feat(http): add cors handling --- .../http-client/src/adapters/FetchAdapter.ts | 1 + .../http-client/src/adapters/XhrAdapter.ts | 1 + .../asserts/assertValidHttpClientOptions.ts | 19 +++- .../src/asserts/assertValidRequestConfig.ts | 7 +- .../src/asserts/assertValidWithCredentials.ts | 11 +++ services/http-client/src/asserts/index.ts | 1 + .../src/constants/requestBuilderErrorCodes.ts | 3 +- services/http-client/src/core/HttpClient.ts | 9 +- .../src/core/HttpRequestBuilder.ts | 26 ++--- .../src/types/HttpClientOptions.ts | 2 + .../src/types/HttpRequestConfig.ts | 1 + .../test/core/HttpClient.constructor.tests.ts | 6 ++ .../test/core/HttpClient.credentials.tests.ts | 97 +++++++++++++++++++ .../test/core/HttpRequestBuilder.tests-d.ts | 1 + .../test/core/HttpRequestBuilder.tests.ts | 7 ++ 15 files changed, 169 insertions(+), 23 deletions(-) create mode 100644 services/http-client/src/asserts/assertValidWithCredentials.ts create mode 100644 services/http-client/test/core/HttpClient.credentials.tests.ts diff --git a/services/http-client/src/adapters/FetchAdapter.ts b/services/http-client/src/adapters/FetchAdapter.ts index 874ffa67..0987d116 100644 --- a/services/http-client/src/adapters/FetchAdapter.ts +++ b/services/http-client/src/adapters/FetchAdapter.ts @@ -155,6 +155,7 @@ export class FetchAdapter implements IHttpClientAdapter { method: config.method, headers: request.requestHeaders, body: request.body, + credentials: config.withCredentials === true ? 'include' : 'same-origin', signal: request.signal }); diff --git a/services/http-client/src/adapters/XhrAdapter.ts b/services/http-client/src/adapters/XhrAdapter.ts index ecbb296d..fd96b475 100644 --- a/services/http-client/src/adapters/XhrAdapter.ts +++ b/services/http-client/src/adapters/XhrAdapter.ts @@ -225,6 +225,7 @@ function prepareXhrRequest(config: IHttpRequestConfig): IPreparedXhrRequest { const xhr = new XMLHttpRequest(); xhr.open(method, fullUrl, true); + xhr.withCredentials = config.withCredentials === true; if (timeout !== undefined && timeout > 0) { xhr.timeout = timeout; diff --git a/services/http-client/src/asserts/assertValidHttpClientOptions.ts b/services/http-client/src/asserts/assertValidHttpClientOptions.ts index 3590fdc5..733a6230 100644 --- a/services/http-client/src/asserts/assertValidHttpClientOptions.ts +++ b/services/http-client/src/asserts/assertValidHttpClientOptions.ts @@ -5,6 +5,7 @@ import { assertValidBaseUrl } from './assertValidBaseUrl'; import { assertValidHeaders } from './assertValidHeaders'; import { assertValidParams } from './assertValidParams'; import { assertValidTimeout } from './assertValidTimeout'; +import { assertValidWithCredentials } from './assertValidWithCredentials'; import { isRecord } from './isRecord'; function assertValidAdapter(adapter: unknown): asserts adapter is IHttpClientAdapter { @@ -32,8 +33,18 @@ export function assertValidHttpClientOptions(options: unknown): asserts options throw new RequestBuilderError('Client options must be an object', REQUEST_BUILDER_ERROR_CODES.INVALID_CONFIG); } - const { adapter, baseUrl, headers, params, timeout, onRequest, onRequestError, onResponse, onResponseError } = - options; + const { + adapter, + baseUrl, + headers, + params, + timeout, + withCredentials, + onRequest, + onRequestError, + onResponse, + onResponseError + } = options; if (adapter !== undefined) { assertValidAdapter(adapter); @@ -55,6 +66,10 @@ export function assertValidHttpClientOptions(options: unknown): asserts options assertValidTimeout(timeout); } + if (withCredentials !== undefined) { + assertValidWithCredentials(withCredentials); + } + if (onRequest !== undefined) { assertValidHook(onRequest, 'onRequest'); } diff --git a/services/http-client/src/asserts/assertValidRequestConfig.ts b/services/http-client/src/asserts/assertValidRequestConfig.ts index 7cae1814..e3b8cebf 100644 --- a/services/http-client/src/asserts/assertValidRequestConfig.ts +++ b/services/http-client/src/asserts/assertValidRequestConfig.ts @@ -10,6 +10,7 @@ import { assertValidResponseType } from './assertValidResponseType'; import { assertValidSignal } from './assertValidSignal'; import { assertValidTimeout } from './assertValidTimeout'; import { assertValidUrl } from './assertValidUrl'; +import { assertValidWithCredentials } from './assertValidWithCredentials'; import { isRecord } from './isRecord'; /** Validates a complete request config, including values returned by request hooks. */ @@ -18,7 +19,7 @@ export function assertValidRequestConfig(config: unknown): asserts config is IHt throw new RequestBuilderError('Request config must be an object', REQUEST_BUILDER_ERROR_CODES.INVALID_CONFIG); } - const { method, url, baseUrl, headers, params, signal, timeout, responseType } = config; + const { method, url, baseUrl, headers, params, signal, timeout, withCredentials, responseType } = config; assertValidMethod(method); assertValidUrl(url); @@ -43,6 +44,10 @@ export function assertValidRequestConfig(config: unknown): asserts config is IHt assertValidTimeout(timeout); } + if (withCredentials !== undefined) { + assertValidWithCredentials(withCredentials); + } + if (responseType !== undefined) { assertValidResponseType(responseType); } 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/index.ts b/services/http-client/src/asserts/index.ts index 8a83b261..06edae7d 100644 --- a/services/http-client/src/asserts/index.ts +++ b/services/http-client/src/asserts/index.ts @@ -12,4 +12,5 @@ export * from './assertValidResponseType'; export * from './assertValidSignal'; export * from './assertValidTimeout'; export * from './assertValidUrl'; +export * from './assertValidWithCredentials'; export * from './headerValueLineBreakPattern'; diff --git a/services/http-client/src/constants/requestBuilderErrorCodes.ts b/services/http-client/src/constants/requestBuilderErrorCodes.ts index 505f3eb1..7a6b3992 100644 --- a/services/http-client/src/constants/requestBuilderErrorCodes.ts +++ b/services/http-client/src/constants/requestBuilderErrorCodes.ts @@ -14,5 +14,6 @@ export const REQUEST_BUILDER_ERROR_CODES = { INVALID_RESPONSE_TYPE: 'INVALID_RESPONSE_TYPE', INVALID_SIGNAL: 'INVALID_SIGNAL', INVALID_TIMEOUT: 'INVALID_TIMEOUT', - INVALID_URL: 'INVALID_URL' + INVALID_URL: 'INVALID_URL', + INVALID_WITH_CREDENTIALS: 'INVALID_WITH_CREDENTIALS' } as const; diff --git a/services/http-client/src/core/HttpClient.ts b/services/http-client/src/core/HttpClient.ts index 00825156..e74216c2 100644 --- a/services/http-client/src/core/HttpClient.ts +++ b/services/http-client/src/core/HttpClient.ts @@ -18,7 +18,10 @@ import { mergeHeaders, mergeParams } from '../utilities'; export class HttpClient { private readonly adapter: IHttpClientAdapter; - private readonly defaultConfig: Pick; + private readonly defaultConfig: Pick< + IHttpRequestConfig, + 'baseUrl' | 'headers' | 'timeout' | 'withCredentials' | 'params' + >; private onRequestHook?: THttpRequestHook; private onRequestErrorHook?: THttpRequestErrorHook; private onResponseHook?: THttpResponseHook; @@ -37,7 +40,8 @@ export class HttpClient { baseUrl: options.baseUrl, headers: options.headers, params: mergeParams(options.params), - timeout: options.timeout + timeout: options.timeout, + withCredentials: options.withCredentials }; } @@ -116,6 +120,7 @@ export class HttpClient { ...config, baseUrl: config.baseUrl ?? this.defaultConfig.baseUrl, timeout: config.timeout ?? this.defaultConfig.timeout, + withCredentials: config.withCredentials ?? this.defaultConfig.withCredentials, headers: mergeHeaders(this.defaultConfig.headers, config.headers), params: mergeParams(this.defaultConfig.params, config.params) }; diff --git a/services/http-client/src/core/HttpRequestBuilder.ts b/services/http-client/src/core/HttpRequestBuilder.ts index ee9cfa62..64591371 100644 --- a/services/http-client/src/core/HttpRequestBuilder.ts +++ b/services/http-client/src/core/HttpRequestBuilder.ts @@ -24,7 +24,8 @@ import { assertValidResponseType, assertValidSignal, assertValidTimeout, - assertValidUrl + assertValidUrl, + assertValidWithCredentials } from '../asserts'; import { mergeHeaders, mergeParams } from '../utilities'; @@ -128,6 +129,13 @@ export class HttpRequestBuilder { return this.withConfig({ timeout }); } + /** Includes credentials in cross-origin requests. */ + public withCredentials(withCredentials: boolean): HttpRequestBuilder { + assertValidWithCredentials(withCredentials); + + return this.withConfig({ withCredentials }); + } + public bearer(token: string): HttpRequestBuilder { assertNonBlankString( token, @@ -151,11 +159,6 @@ export class HttpRequestBuilder { return this.withConfig({ responseType }); } - // public credentials(credentials: RequestCredentials): this { - // this.config.credentials = credentials; - // return this; - // } - // public mode(mode: RequestMode): this { // this.config.mode = mode; // return this; @@ -198,12 +201,6 @@ export class HttpRequestBuilder { // return this; // } - // public credentials(value: RequestCredentials): this { - // this.config = { ...this.config, credentials: value }; - - // return this; - // } - // public mode(value: RequestMode): this { // this.config = { ...this.config, mode: value }; @@ -222,11 +219,6 @@ export class HttpRequestBuilder { // return this; // } - // withCredentials(value: boolean): this { - // this.config.withCredentials = value; - // return this; - // } - // json(): this { // this.header("Content-Type", "application/json"); // return this; diff --git a/services/http-client/src/types/HttpClientOptions.ts b/services/http-client/src/types/HttpClientOptions.ts index 39e3ee30..0cf83783 100644 --- a/services/http-client/src/types/HttpClientOptions.ts +++ b/services/http-client/src/types/HttpClientOptions.ts @@ -10,6 +10,8 @@ export interface IHttpClientOptions { headers?: THttpHeaders; params?: THttpParams; timeout?: number; + /** Sends cookies and HTTP credentials with cross-origin requests. */ + withCredentials?: boolean; onRequest?: THttpRequestHook; onRequestError?: THttpRequestErrorHook; onResponse?: THttpResponseHook; diff --git a/services/http-client/src/types/HttpRequestConfig.ts b/services/http-client/src/types/HttpRequestConfig.ts index 5f956a8b..9bd742ce 100644 --- a/services/http-client/src/types/HttpRequestConfig.ts +++ b/services/http-client/src/types/HttpRequestConfig.ts @@ -12,5 +12,6 @@ export interface IHttpRequestConfig { readonly data?: TData; readonly signal?: AbortSignal; readonly timeout?: number; + readonly withCredentials?: boolean; readonly responseType?: THttpResponseType; } diff --git a/services/http-client/test/core/HttpClient.constructor.tests.ts b/services/http-client/test/core/HttpClient.constructor.tests.ts index 655e19a7..aabc9f00 100644 --- a/services/http-client/test/core/HttpClient.constructor.tests.ts +++ b/services/http-client/test/core/HttpClient.constructor.tests.ts @@ -55,6 +55,11 @@ describe('HttpClient constructor', () => { options: { ...createOptions(), timeout: -1 }, code: REQUEST_BUILDER_ERROR_CODES.INVALID_TIMEOUT }, + { + name: 'with credentials', + options: { ...createOptions(), withCredentials: 'true' }, + code: REQUEST_BUILDER_ERROR_CODES.INVALID_WITH_CREDENTIALS + }, { name: 'onRequest hook', options: { ...createOptions(), onRequest: true }, @@ -86,6 +91,7 @@ describe('HttpClient constructor', () => { headers: { 'X-Default': 'value' }, params: { locale: 'ru' }, timeout: 1000, + withCredentials: true, onRequest: config => config, onRequestError: () => undefined, onResponse: response => response, 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/HttpRequestBuilder.tests-d.ts b/services/http-client/test/core/HttpRequestBuilder.tests-d.ts index 5e16dc48..ed094a5d 100644 --- a/services/http-client/test/core/HttpRequestBuilder.tests-d.ts +++ b/services/http-client/test/core/HttpRequestBuilder.tests-d.ts @@ -30,6 +30,7 @@ describe('HttpRequestBuilder types', () => { expectTypeOf(getBuilder.body({ value: true })).toEqualTypeOf(); expectTypeOf(headBuilder.body({ value: true })).toEqualTypeOf(); expectTypeOf(postBuilder.body({ value: true })).toEqualTypeOf(); + expectTypeOf(postBuilder.withCredentials(true)).toEqualTypeOf(); }); it('allows primitive query params', () => { diff --git a/services/http-client/test/core/HttpRequestBuilder.tests.ts b/services/http-client/test/core/HttpRequestBuilder.tests.ts index 0a115a9b..47d3fda8 100644 --- a/services/http-client/test/core/HttpRequestBuilder.tests.ts +++ b/services/http-client/test/core/HttpRequestBuilder.tests.ts @@ -49,6 +49,7 @@ describe('HttpRequestBuilder', () => { .body({ name: 'Item' }) .signal(controller.signal) .timeout(0) + .withCredentials(true) .bearer('token') .responseType(HTTP_RESPONSE_TYPES.JSON); @@ -67,6 +68,7 @@ describe('HttpRequestBuilder', () => { data: { name: 'Item' }, signal: controller.signal, timeout: 0, + withCredentials: true, responseType: HTTP_RESPONSE_TYPES.JSON }); }); @@ -202,6 +204,11 @@ describe('HttpRequestBuilder', () => { 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: 'bearer token', action: (builder: HttpRequestBuilder) => builder.bearer(' '), From 4652d2323b44f6e266c5963aef01e93495c97694 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Fri, 21 Aug 2026 11:33:57 +0500 Subject: [PATCH 40/60] feat(http): add specific adapter options --- .../http-client/src/adapters/FetchAdapter.ts | 36 +++- .../http-client/src/adapters/XhrAdapter.ts | 48 ++++- .../asserts/assertValidFetchAdapterOptions.ts | 77 +++++++ .../asserts/assertValidXhrAdapterOptions.ts | 36 ++++ services/http-client/src/asserts/index.ts | 2 + .../src/constants/requestBuilderErrorCodes.ts | 4 +- .../src/types/FetchAdapterOptions.ts | 11 + .../src/types/XhrAdapterOptions.ts | 9 + services/http-client/src/types/index.ts | 2 + .../test/adapters/AdapterOptions.tests.ts | 195 ++++++++++++++++++ .../test/types/AdapterOptions.tests-d.ts | 25 +++ 11 files changed, 437 insertions(+), 8 deletions(-) create mode 100644 services/http-client/src/asserts/assertValidFetchAdapterOptions.ts create mode 100644 services/http-client/src/asserts/assertValidXhrAdapterOptions.ts create mode 100644 services/http-client/src/types/FetchAdapterOptions.ts create mode 100644 services/http-client/src/types/XhrAdapterOptions.ts create mode 100644 services/http-client/test/adapters/AdapterOptions.tests.ts create mode 100644 services/http-client/test/types/AdapterOptions.tests-d.ts diff --git a/services/http-client/src/adapters/FetchAdapter.ts b/services/http-client/src/adapters/FetchAdapter.ts index 0987d116..e7bafb1f 100644 --- a/services/http-client/src/adapters/FetchAdapter.ts +++ b/services/http-client/src/adapters/FetchAdapter.ts @@ -6,7 +6,15 @@ import { ParseError } from '../errors/ParseError'; import { RequestPreparationError } from '../errors/RequestPreparationError'; import { TimeoutError } from '../errors/TimeoutError'; import { AbortError } from '../errors/AbortError'; -import { IHttpClientAdapter, IHttpRequestConfig, IHttpResponse, THttpHeaders, THttpResponseType } from '../types'; +import { + IFetchAdapterOptions, + IHttpClientAdapter, + IHttpRequestConfig, + IHttpResponse, + THttpHeaders, + THttpResponseType +} from '../types'; +import { assertValidFetchAdapterOptions } from '../asserts'; import { buildUrl, getErrorMessage, mergeHeaders, prepareRequestBody } from '../utilities'; function extractResponseHeaders(headers: Headers): THttpHeaders { @@ -87,6 +95,21 @@ function prepareFetchRequest(config: IHttpRequestConfig): IPreparedFetchRequest } } +function resolveCredentials( + withCredentials: boolean | undefined, + adapterCredentials: RequestCredentials | undefined +): RequestCredentials { + if (withCredentials === true) { + return 'include'; + } + + if (withCredentials === false) { + return 'same-origin'; + } + + return adapterCredentials ?? 'same-origin'; +} + async function parseResponseBody( response: Response, config: IHttpRequestConfig, @@ -137,6 +160,14 @@ async function parseResponseBody( } 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; @@ -152,10 +183,11 @@ export class FetchAdapter implements IHttpClientAdapter { try { const response = await fetch(request.fullUrl, { + ...this.options, method: config.method, headers: request.requestHeaders, body: request.body, - credentials: config.withCredentials === true ? 'include' : 'same-origin', + credentials: resolveCredentials(config.withCredentials, this.options.credentials), signal: request.signal }); diff --git a/services/http-client/src/adapters/XhrAdapter.ts b/services/http-client/src/adapters/XhrAdapter.ts index fd96b475..3a34f7ac 100644 --- a/services/http-client/src/adapters/XhrAdapter.ts +++ b/services/http-client/src/adapters/XhrAdapter.ts @@ -6,7 +6,15 @@ import { ParseError } from '../errors/ParseError'; import { RequestPreparationError } from '../errors/RequestPreparationError'; import { TimeoutError } from '../errors/TimeoutError'; import { AbortError } from '../errors/AbortError'; -import { IHttpClientAdapter, IHttpRequestConfig, IHttpResponse, THttpHeaders, THttpResponseType } from '../types'; +import { + IHttpClientAdapter, + IHttpRequestConfig, + IHttpResponse, + IXhrAdapterOptions, + THttpHeaders, + THttpResponseType +} from '../types'; +import { assertValidXhrAdapterOptions } from '../asserts'; import { buildUrl, getErrorMessage, mergeHeaders, prepareRequestBody } from '../utilities'; function parseResponseHeaders(rawHeaders: string): THttpHeaders { @@ -203,7 +211,20 @@ function createResponseError(xhr: XMLHttpRequest, config: IHttpRequestConfig): H ); } -function prepareXhrRequest(config: IHttpRequestConfig): IPreparedXhrRequest { +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, @@ -227,6 +248,10 @@ function prepareXhrRequest(config: IHttpRequestConfig): IPreparedXhrRequest { 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; } @@ -347,20 +372,33 @@ function configureXhrEventHandlers( } 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(config); - configureXhrEventHandlers(request, config, resolve, reject); + request = prepareXhrRequest(resolvedConfig, this.options); + configureXhrEventHandlers(request, resolvedConfig, 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 }) + : new RequestPreparationError('Failed to prepare HTTP request', { + cause: error, + config: resolvedConfig + }) ); } }); diff --git a/services/http-client/src/asserts/assertValidFetchAdapterOptions.ts b/services/http-client/src/asserts/assertValidFetchAdapterOptions.ts new file mode 100644 index 00000000..a1d3f8a4 --- /dev/null +++ b/services/http-client/src/asserts/assertValidFetchAdapterOptions.ts @@ -0,0 +1,77 @@ +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', 'navigate', '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 (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/assertValidXhrAdapterOptions.ts b/services/http-client/src/asserts/assertValidXhrAdapterOptions.ts new file mode 100644 index 00000000..31b2ff15 --- /dev/null +++ b/services/http-client/src/asserts/assertValidXhrAdapterOptions.ts @@ -0,0 +1,36 @@ +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 } = 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); + } +} diff --git a/services/http-client/src/asserts/index.ts b/services/http-client/src/asserts/index.ts index 06edae7d..19d25a34 100644 --- a/services/http-client/src/asserts/index.ts +++ b/services/http-client/src/asserts/index.ts @@ -1,4 +1,5 @@ export * from './assertBodyAllowed'; +export * from './assertValidFetchAdapterOptions'; export * from './assertNonBlankString'; export * from './assertValidBaseUrl'; export * from './assertValidHeader'; @@ -13,4 +14,5 @@ export * from './assertValidSignal'; export * from './assertValidTimeout'; export * from './assertValidUrl'; export * from './assertValidWithCredentials'; +export * from './assertValidXhrAdapterOptions'; export * from './headerValueLineBreakPattern'; diff --git a/services/http-client/src/constants/requestBuilderErrorCodes.ts b/services/http-client/src/constants/requestBuilderErrorCodes.ts index 7a6b3992..6639643d 100644 --- a/services/http-client/src/constants/requestBuilderErrorCodes.ts +++ b/services/http-client/src/constants/requestBuilderErrorCodes.ts @@ -5,6 +5,7 @@ export const REQUEST_BUILDER_ERROR_CODES = { 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', @@ -15,5 +16,6 @@ export const REQUEST_BUILDER_ERROR_CODES = { INVALID_SIGNAL: 'INVALID_SIGNAL', INVALID_TIMEOUT: 'INVALID_TIMEOUT', INVALID_URL: 'INVALID_URL', - INVALID_WITH_CREDENTIALS: 'INVALID_WITH_CREDENTIALS' + INVALID_WITH_CREDENTIALS: 'INVALID_WITH_CREDENTIALS', + INVALID_XHR_ADAPTER_OPTIONS: 'INVALID_XHR_ADAPTER_OPTIONS' } as const; diff --git a/services/http-client/src/types/FetchAdapterOptions.ts b/services/http-client/src/types/FetchAdapterOptions.ts new file mode 100644 index 00000000..e90dc99d --- /dev/null +++ b/services/http-client/src/types/FetchAdapterOptions.ts @@ -0,0 +1,11 @@ +/** Settings that are supported only by the Fetch transport. */ +export interface IFetchAdapterOptions { + cache?: RequestCache; + credentials?: RequestCredentials; + integrity?: string; + keepalive?: boolean; + mode?: RequestMode; + redirect?: RequestRedirect; + referrer?: string; + referrerPolicy?: ReferrerPolicy; +} diff --git a/services/http-client/src/types/XhrAdapterOptions.ts b/services/http-client/src/types/XhrAdapterOptions.ts new file mode 100644 index 00000000..1ad50ba6 --- /dev/null +++ b/services/http-client/src/types/XhrAdapterOptions.ts @@ -0,0 +1,9 @@ +import { THttpResponseType } from './HttpResponseType'; + +/** Settings that are supported only by the XMLHttpRequest transport. */ +export interface IXhrAdapterOptions { + mimeType?: string; + responseType?: THttpResponseType; + timeout?: number; + withCredentials?: boolean; +} diff --git a/services/http-client/src/types/index.ts b/services/http-client/src/types/index.ts index d0a8447d..d2de229f 100644 --- a/services/http-client/src/types/index.ts +++ b/services/http-client/src/types/index.ts @@ -2,6 +2,7 @@ export * from './HttpMethod'; export * from './HttpStatusCode'; export * from './HttpClientAdapter'; export * from './HttpClientOptions'; +export * from './FetchAdapterOptions'; export * from './HttpRequestConfig'; export * from './HttpRequestBody'; export * from './HttpRequestExecutor'; @@ -11,3 +12,4 @@ export * from './HttpHooks'; export * from './HttpParams'; export * from './HttpResponseType'; export * from './RequestBuilderErrorCode'; +export * from './XhrAdapterOptions'; 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..540bd6e0 --- /dev/null +++ b/services/http-client/test/adapters/AdapterOptions.tests.ts @@ -0,0 +1,195 @@ +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 { TRequestBuilderErrorCode } from '../../src/types'; + +function createMockXhr(response: unknown = 'response'): { + xhr: XMLHttpRequest; + overrideMimeType: ReturnType; +} { + const overrideMimeType = vi.fn(); + const xhr = { + status: HTTP_STATUS_CODES.OK, + statusText: 'OK', + response, + responseText: typeof response === 'string' ? response : '', + responseType: '', + timeout: 0, + withCredentials: false, + 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) + } as unknown as XMLHttpRequest; + + xhr.send = vi.fn(() => { + xhr.onload?.(new ProgressEvent('load')); + }); + + return { xhr, overrideMimeType }; +} + +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('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') + .responseType(HTTP_RESPONSE_TYPES.ARRAY_BUFFER) + .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.each([ + { + name: 'Fetch options object', + action: () => new FetchAdapter(null as unknown as Record), + code: REQUEST_BUILDER_ERROR_CODES.INVALID_FETCH_ADAPTER_OPTIONS + }, + { + name: 'Fetch mode', + action: () => new FetchAdapter({ mode: 'unsupported' as RequestMode }), + 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 + } + ])('rejects invalid $name', ({ action, code }) => { + expectRequestBuilderError(action, code); + }); +}); 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..d5bdd710 --- /dev/null +++ b/services/http-client/test/types/AdapterOptions.tests-d.ts @@ -0,0 +1,25 @@ +import { FetchAdapter, IFetchAdapterOptions, 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 + }; + + expectTypeOf(new FetchAdapter(fetchOptions)).toEqualTypeOf(); + expectTypeOf(new XhrAdapter(xhrOptions)).toEqualTypeOf(); + }); +}); From 4fac4b98063d8ccc8ac917b1637a002974fb2879 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Fri, 21 Aug 2026 13:31:44 +0500 Subject: [PATCH 41/60] feat(http): fix issues --- .../http-client/src/adapters/FetchAdapter.ts | 7 ++++ .../src/errors/HttpResponseError.ts | 2 +- .../test/core/HttpClient.GET.tests.ts | 36 ++++++++++++++++++- .../test/errors/HttpClientError.tests.ts | 3 +- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/services/http-client/src/adapters/FetchAdapter.ts b/services/http-client/src/adapters/FetchAdapter.ts index e7bafb1f..496ffd77 100644 --- a/services/http-client/src/adapters/FetchAdapter.ts +++ b/services/http-client/src/adapters/FetchAdapter.ts @@ -137,6 +137,13 @@ async function parseResponseBody( }); } 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: { diff --git a/services/http-client/src/errors/HttpResponseError.ts b/services/http-client/src/errors/HttpResponseError.ts index fbba11fd..7c0961cf 100644 --- a/services/http-client/src/errors/HttpResponseError.ts +++ b/services/http-client/src/errors/HttpResponseError.ts @@ -18,7 +18,7 @@ export class HttpResponseError extends HttpClientError { data?: T, options?: ErrorOptions ) { - super(message, options); + super(message, { ...options, config }); this.status = status; this.statusText = statusText; diff --git a/services/http-client/test/core/HttpClient.GET.tests.ts b/services/http-client/test/core/HttpClient.GET.tests.ts index 8482deab..738e5595 100644 --- a/services/http-client/test/core/HttpClient.GET.tests.ts +++ b/services/http-client/test/core/HttpClient.GET.tests.ts @@ -20,7 +20,7 @@ const adapters: Array<{ name: string; create: () => IHttpClientAdapter }> = [ { name: 'XhrAdapter', create: () => new XhrAdapter() } ]; -describe.each(adapters)('HttpClient.$name — GET', ({ create }) => { +describe.each(adapters)('HttpClient.$name — GET', ({ name, create }) => { function createClient(): HttpClient { return new HttpClient({ adapter: create(), @@ -161,6 +161,40 @@ describe.each(adapters)('HttpClient.$name — GET', ({ create }) => { 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(async () => { + return { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + body: null + } as unknown as Response; + }) + ); + + try { + await client.get('/stream').responseType(HTTP_RESPONSE_TYPES.STREAM).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(); diff --git a/services/http-client/test/errors/HttpClientError.tests.ts b/services/http-client/test/errors/HttpClientError.tests.ts index 58b8820a..93927689 100644 --- a/services/http-client/test/errors/HttpClientError.tests.ts +++ b/services/http-client/test/errors/HttpClientError.tests.ts @@ -87,7 +87,7 @@ describe('HttpClientError', () => { expect(guard(new Error('unrelated'))).toBe(false); }); - it('preserves HttpResponseError fields', () => { + 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' @@ -99,6 +99,7 @@ describe('HttpClientError', () => { 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', () => { From 944083dbeb730eefd097ee422b91268013ed0f69 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Fri, 21 Aug 2026 13:33:58 +0500 Subject: [PATCH 42/60] feat(http): delete comments --- .../src/core/HttpRequestBuilder.ts | 85 ------------------- 1 file changed, 85 deletions(-) diff --git a/services/http-client/src/core/HttpRequestBuilder.ts b/services/http-client/src/core/HttpRequestBuilder.ts index 64591371..73c74fa1 100644 --- a/services/http-client/src/core/HttpRequestBuilder.ts +++ b/services/http-client/src/core/HttpRequestBuilder.ts @@ -106,13 +106,8 @@ export class HttpRequestBuilder { } public body(data: unknown): HttpRequestBuilder { - // Возможно ещё и для DELETE assertBodyAllowed(this.config.method); - // if (typeof data !== 'object') { - // throw new RequestBuilderError('Data must be an object'); - // } - return this.withConfig({ data }); } @@ -159,86 +154,6 @@ export class HttpRequestBuilder { return this.withConfig({ responseType }); } - // public mode(mode: RequestMode): this { - // this.config.mode = mode; - // return this; - // } - - // public cache(cache: RequestCache): this { - // this.config.cache = cache; - // return this; - // } - - // public redirect(redirect: RequestRedirect): this { - // this.config.redirect = redirect; - // return this; - // } - - // public referrer(referrer: string): this { - // this.config.referrer = referrer; - // return this; - // } - - // public integrity(integrity: string): this { - // this.config.integrity = integrity; - // return this; - // } - - // public keepalive(keepalive: boolean): this { - // this.config.keepalive = keepalive; - // return this; - // } - - // public keepalive(value: boolean): this { - // this.config = { ...this.config, keepalive: value }; - - // return this; - // } - - // public cache(value: RequestCache): this { - // this.config = { ...this.config, cache: value }; - - // return this; - // } - - // public mode(value: RequestMode): this { - // this.config = { ...this.config, mode: value }; - - // return this; - // } - // - // public redirect(value: RequestRedirect): this { - // this.config = { ...this.config, redirect: value }; - - // return this; - // } - - // public referrer(value: string): this { - // this.config = { ...this.config, referrer: value }; - - // return this; - // } - - // json(): this { - // this.header("Content-Type", "application/json"); - // return this; - // } - - // form(): this { - // this.header("Content-Type", "application/x-www-form-urlencoded"); - // return this; - // } - - // multipart(): this { - // this.header("Content-Type", "multipart/form-data"); - // return this; - // } - - // acceptJson(): this { - // this.header("Accept", "application/json"); - // return this; - // } - public execute(): Promise> { return this.executor(this.build()); } From 503e4d133d8249cd4abc242d772426e2a09d98b9 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Fri, 21 Aug 2026 13:48:28 +0500 Subject: [PATCH 43/60] feat(http): add todo --- services/http-client/TODO.md | 98 ++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 services/http-client/TODO.md diff --git a/services/http-client/TODO.md b/services/http-client/TODO.md new file mode 100644 index 00000000..228f5920 --- /dev/null +++ b/services/http-client/TODO.md @@ -0,0 +1,98 @@ +# TODO — @byndyusoft-ui/http-client + +## P0 + +Нет критичных доработок, блокирующих использование пакета. + +## P1 + +### Типизировать тело HTTP-ошибки + +`HttpResponseError` уже параметризован, но адаптеры создают его с `unknown`, а guard не позволяет вызывающему коду указать ожидаемый тип. Нужен явный сценарий для `400` и `422`, например `isHttpResponseError(error)`, плюс type-tests. + +### Уточнить валидацию опций `FetchAdapter` + +Нужно отклонять значения и сочетания, которые Fetch не поддерживает при программном запросе: например, `mode: 'navigate'` и `cache: 'only-if-cached'` без `mode: 'same-origin'`. Ошибка должна возникать при создании адаптера, а не во время отправки запроса. Нужны также тесты на эти комбинации. + +### Добавить и закрепить документацию пакета + +Нужны README и примеры: настройка Fetch/XHR, приоритеты конфигурации (client → builder → adapter), `withCredentials`, ограничения CORS, таблица ошибок и hooks. Без этого публичный API плохо discoverable. + +### Добавить браузерные интеграционные тесты + +Проверить в настоящих браузерах credentialed CORS, preflight, cookies с `SameSite`/`Secure`, redirects и `keepalive`. Тесты jsdom и mock-адаптеров не воспроизводят эти особенности платформы. + +### Выровнять parity Fetch ↔ XHR + +XHR не учитывает `Content-Length: 0` (Fetch уже учитывает). `getErrorData` читает тело ошибки только при `responseType === 'text'`, поэтому 4xx/5xx при `blob`/`formData` почти всегда без `data`. Выровнять семантику empty body и error body + тесты на оба адаптера. + +### Клонировать default `headers` в конструкторе клиента + +`params` копируются через `mergeParams`, а `headers` сохраняются по ссылке — внешняя мутация объекта влияет на клиент. Нужно `headers: mergeHeaders(options.headers)` (или аналог) и тест на иммутабельность. + +## P2 + +### Спроектировать opt-in retry + +Добавить повторы для временных сетевых сбоев, `408`, `429` и части `5xx`: с лимитом попыток, exponential backoff, поддержкой `Retry-After`, отменой через `AbortSignal` и безопасным поведением для мутаций. Предпочтительно middleware/hook, а не жёсткая логика в core. + +### Добавить progress API для XHR + +Выделить публичный контракт для `onDownloadProgress` и `onUploadProgress`. Нужно документировать, что upload-progress является преимуществом XHR и не имеет стандартного аналога в Fetch. + +### Добавить `validateStatus` + +Сейчас любой статус вне 2xx → `HttpResponseError`. Нужна опция `validateStatus?: (status) => boolean` (client/request), чтобы 404/304 и т.п. можно было считать успехом. + +### Не класть пустой объект `params` в merged config + +`mergeParams` всегда возвращает `{}`. Если источников нет или после merge ключей не осталось — лучше `undefined`, чтобы не шуметь в config/логах. + +### Юнит-тесты утилит + +Изолированно покрыть `buildUrl`, `mergeHeaders`, `mergeParams`, `prepareRequestBody` (включая FormData без Content-Type, URLSearchParams, circular JSON). + +### Зафиксировать edge-cases XHR stream + +Поведение abort/timeout после resolve stream и 4xx при `responseType: 'stream'` должно быть явным контрактом (reject promise vs `stream.error`) с тестами. + +### Единый язык public surface + +Свести JSDoc и сообщения ошибок к одному языку (EN для npm API); убрать RU/EN mix во внутренних комментариях публичных модулей. + +## P3 + +### Развить hooks до композиционного pipeline + +Сейчас на каждую фазу можно назначить лишь один хук, а следующий вызов заменяет предыдущий. При совместном использовании auth, tracing и логирования потребуется композиция хуков с предсказуемым порядком и правилами восстановления. + +### Convenience-методы builder + +Опциональный сахар: `json()`, `acceptJson()`, `multipart()` поверх headers. + +### Release hygiene + +CHANGELOG, examples, полный JSDoc на public exports; semver `0.0.1` → `0.1.0` при первом publish; поле `exports` в `package.json`. + +## Рекомендуемый порядок + +1. P1: typed errors + Fetch options validation + README +2. P1: adapter parity + headers clone +3. P1: browser e2e +4. P2: retry / progress по продуктовым нуждам +5. P2–P3: DX и полировка + +## Закрыто + +- Модель ошибок + guards (`HttpResponseError`, `ParseError`, `cause`/`config`) +- `TimeoutError.timeout`, `ParseError.responseType` / `raw` +- Body: FormData / URLSearchParams / Blob / ArrayBufferView (`prepareRequestBody`) +- Default `params`, number/boolean params, null-skip +- Options/config validation, `RequestPreparationError` +- formData / stream response types +- `FetchAdapter`: `ParseError`, если streaming body отсутствует (`response.body === null`) +- `HttpResponseError` передаёт `config` в базовый `HttpClientError` +- Default `FetchAdapter`, `withCredentials` (client / builder / оба адаптера) +- Adapter options (`FetchAdapterOptions`, `XhrAdapterOptions`) +- Удалены устаревшие закомментированные методы из `HttpRequestBuilder` +- Publish hygiene: `files: ["dist"]`, `.npmignore` From 8d1fc85b10accc3125ee5b29cfef1a5f198bbe02 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Fri, 21 Aug 2026 14:50:46 +0500 Subject: [PATCH 44/60] feat(http): add decisions --- services/http-client/DECISIONS.md | 27 ++++++++ services/http-client/TODO.md | 102 ++++++++++++++++-------------- 2 files changed, 82 insertions(+), 47 deletions(-) create mode 100644 services/http-client/DECISIONS.md diff --git a/services/http-client/DECISIONS.md b/services/http-client/DECISIONS.md new file mode 100644 index 00000000..04840de2 --- /dev/null +++ b/services/http-client/DECISIONS.md @@ -0,0 +1,27 @@ +# Принятые решения — @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-специфичные параметры для одного запроса. diff --git a/services/http-client/TODO.md b/services/http-client/TODO.md index 228f5920..f6dfcde5 100644 --- a/services/http-client/TODO.md +++ b/services/http-client/TODO.md @@ -1,98 +1,106 @@ # TODO — @byndyusoft-ui/http-client +Связанные архитектурные решения зафиксированы в [DECISIONS.md](./DECISIONS.md). + ## P0 -Нет критичных доработок, блокирующих использование пакета. +### P0-1 — Релизная гигиена: README + changeset + версия + +`homepage` в package.json указывает на несуществующий `#readme`, `.changeset/` пуст, версия `0.0.1` → `0.1.0` при первом publish. Нужны README с примерами (настройка Fetch/XHR, приоритеты конфигурации client → builder → adapter, `withCredentials`, ограничения CORS, таблица ошибок, hooks) и changeset перед паблишем. В README отдельно зафиксировать: маппинг `withCredentials` → fetch `credentials` (`true` → `include`, `false` → `same-origin`, `undefined` → `credentials` адаптера ?? `same-origin`) согласно D-001; контракт `body()` (допустимые типы, автосериализация объектов в JSON с авто-`Content-Type`); ограничение `redirect: 'manual'` (opaque-redirect приходит как `HttpResponseError` со статусом `0`, продолжить редирект вручную нельзя). ## P1 -### Типизировать тело HTTP-ошибки +### P1-1 — Типизировать тело HTTP-ошибки `HttpResponseError` уже параметризован, но адаптеры создают его с `unknown`, а guard не позволяет вызывающему коду указать ожидаемый тип. Нужен явный сценарий для `400` и `422`, например `isHttpResponseError(error)`, плюс type-tests. -### Уточнить валидацию опций `FetchAdapter` - -Нужно отклонять значения и сочетания, которые Fetch не поддерживает при программном запросе: например, `mode: 'navigate'` и `cache: 'only-if-cached'` без `mode: 'same-origin'`. Ошибка должна возникать при создании адаптера, а не во время отправки запроса. Нужны также тесты на эти комбинации. - -### Добавить и закрепить документацию пакета +### P1-2 — Вывод типа данных ответа из `responseType` -Нужны README и примеры: настройка Fetch/XHR, приоритеты конфигурации (client → builder → adapter), `withCredentials`, ограничения CORS, таблица ошибок и hooks. Без этого публичный API плохо discoverable. +`responseType('blob').execute()` возвращает `unknown` — каждый вызов требует явного дженерика, а рассинхрон `responseType('blob')` + `execute()` не ловится компилятором. Нужен `THttpResponseData` + дженерик-builder (`HttpRequestBuilder`), покрыть type-тестами. Сделать до README, чтобы документировать финальный API. -### Добавить браузерные интеграционные тесты +### P1-3 — Клонировать default `headers` в конструкторе клиента -Проверить в настоящих браузерах credentialed CORS, preflight, cookies с `SameSite`/`Secure`, redirects и `keepalive`. Тесты jsdom и mock-адаптеров не воспроизводят эти особенности платформы. +`params` копируются через `mergeParams`, а `headers` сохраняются по ссылке — внешняя мутация объекта влияет на клиент. Нужно `headers: mergeHeaders(options.headers)` (или аналог) и тест на иммутабельность. -### Выровнять parity Fetch ↔ XHR +### P1-4 — Выровнять parity Fetch ↔ XHR -XHR не учитывает `Content-Length: 0` (Fetch уже учитывает). `getErrorData` читает тело ошибки только при `responseType === 'text'`, поэтому 4xx/5xx при `blob`/`formData` почти всегда без `data`. Выровнять семантику empty body и error body + тесты на оба адаптера. +XHR не учитывает `Content-Length: 0` (Fetch уже учитывает). `getErrorData` читает тело ошибки только при `responseType === 'text'`, поэтому 4xx/5xx при `blob`/`formData` почти всегда без `data`. Выровнять семантику empty body и error body + тесты на оба адаптера. Смежное ограничение XHR: парсинг `formData` построен на глобальном `Response` — в окружениях без fetch-API падает без fail-fast; нужен guard с `RequestPreparationError` или явная документация. -### Клонировать default `headers` в конструкторе клиента +### P1-5 — Уточнить валидацию опций `FetchAdapter` -`params` копируются через `mergeParams`, а `headers` сохраняются по ссылке — внешняя мутация объекта влияет на клиент. Нужно `headers: mergeHeaders(options.headers)` (или аналог) и тест на иммутабельность. +Нужно отклонять значения и сочетания, которые Fetch не поддерживает при программном запросе: например, `mode: 'navigate'` и `cache: 'only-if-cached'` без `mode: 'same-origin'`. Ошибка должна возникать при создании адаптера, а не во время отправки запроса. Нужны также тесты на эти комбинации. ## P2 -### Спроектировать opt-in retry +### P2-1 — Модернизация package.json -Добавить повторы для временных сетевых сбоев, `408`, `429` и части `5xx`: с лимитом попыток, exponential backoff, поддержкой `Retry-After`, отменой через `AbortSignal` и безопасным поведением для мутаций. Предпочтительно middleware/hook, а не жёсткая логика в core. +Сборка только CJS: нет `exports`-map (dual ESM/CJS для Vite-потребителей), `sideEffects: false`, `engines` (Node 20). Без ESM-входа недоступен tree-shaking. -### Добавить progress API для XHR +### P2-2 — Добавить progress API для XHR Выделить публичный контракт для `onDownloadProgress` и `onUploadProgress`. Нужно документировать, что upload-progress является преимуществом XHR и не имеет стандартного аналога в Fetch. -### Добавить `validateStatus` +### P2-3 — Добавить `validateStatus` Сейчас любой статус вне 2xx → `HttpResponseError`. Нужна опция `validateStatus?: (status) => boolean` (client/request), чтобы 404/304 и т.п. можно было считать успехом. -### Не класть пустой объект `params` в merged config +### P2-4 — Спроектировать opt-in retry -`mergeParams` всегда возвращает `{}`. Если источников нет или после merge ключей не осталось — лучше `undefined`, чтобы не шуметь в config/логах. +Добавить повторы для временных сетевых сбоев, `408`, `429` и части `5xx`: с лимитом попыток, exponential backoff, поддержкой `Retry-After`, отменой через `AbortSignal` и безопасным поведением для мутаций. Предпочтительно middleware/hook, а не жёсткая логика в core. -### Юнит-тесты утилит +### P2-5 — Юнит-тесты утилит -Изолированно покрыть `buildUrl`, `mergeHeaders`, `mergeParams`, `prepareRequestBody` (включая FormData без Content-Type, URLSearchParams, circular JSON). +Изолированно покрыть `buildUrl`, `mergeHeaders`, `mergeParams`, `prepareRequestBody` (включая FormData без Content-Type, URLSearchParams, circular JSON). Заодно зафиксировать контракт `prepareRequestBody`, который мутирует переданный `headers` (JSDoc или возврат пары значений), и поведение `body(undefined)`: ключ `data` в config создаётся, но адаптеры тело не отправляют. -### Зафиксировать edge-cases XHR stream +### P2-6 — Зафиксировать edge-cases XHR stream -Поведение abort/timeout после resolve stream и 4xx при `responseType: 'stream'` должно быть явным контрактом (reject promise vs `stream.error`) с тестами. +Поведение abort/timeout после resolve stream и 4xx при `responseType: 'stream'` должно быть явным контрактом (reject promise vs `stream.error`) с тестами. Смежное: расходящаяся семантика timeout для STREAM — fetch-таймер живёт до получения заголовков, `xhr.timeout` покрывает всю загрузку; задокументировать или унифицировать. Также задокументировать, что XHR-stream не настоящий стрим: ответ целиком накапливается в `responseText`, что ограничивает размер стриминговых ответов по памяти. -### Единый язык public surface +### P2-7 — Добавить браузерные интеграционные тесты -Свести JSDoc и сообщения ошибок к одному языку (EN для npm API); убрать RU/EN mix во внутренних комментариях публичных модулей. +Проверить в настоящих браузерах credentialed CORS, preflight, cookies с `SameSite`/`Secure`, redirects и `keepalive`. Тесты jsdom и mock-адаптеров не воспроизводят эти особенности платформы. Актуально после появления реальных пользователей credentials-API. ## P3 -### Развить hooks до композиционного pipeline +### P3-1 — Развить hooks до композиционного pipeline Сейчас на каждую фазу можно назначить лишь один хук, а следующий вызов заменяет предыдущий. При совместном использовании auth, tracing и логирования потребуется композиция хуков с предсказуемым порядком и правилами восстановления. -### Convenience-методы builder +### P3-2 — Единый язык public surface + +Свести JSDoc и сообщения ошибок к одному языку (EN для npm API); убрать RU/EN mix во внутренних комментариях публичных модулей. + +### P3-3 — Не класть пустой объект `params` в merged config + +`mergeParams` всегда возвращает `{}`. Если источников нет или после merge ключей не осталось — лучше `undefined`, чтобы не шуметь в config/логах. + +### P3-4 — Convenience-методы builder Опциональный сахар: `json()`, `acceptJson()`, `multipart()` поверх headers. -### Release hygiene +### P3-5 — Чистка конфигурации -CHANGELOG, examples, полный JSDoc на public exports; semver `0.0.1` → `0.1.0` при первом publish; поле `exports` в `package.json`. +`.npmignore` избыточен при `files: ["dist"]` — удалить. Поправить команду запуска тестов пакета в корневом AGENTS.md (`vitest run --root ../../ --project ` не работает; рабочий вариант — `npm test -w @byndyusoft-ui/http-client`). ## Рекомендуемый порядок -1. P1: typed errors + Fetch options validation + README -2. P1: adapter parity + headers clone -3. P1: browser e2e -4. P2: retry / progress по продуктовым нуждам -5. P2–P3: DX и полировка +1. P0: README + changeset (после P1 «вывод типа данных ответа», чтобы документировать финальный API) +2. P1: typed errors + headers clone + adapter parity + Fetch options validation +3. P2: package.json модернизация вместе с релизной подготовкой +4. P2: retry / progress / validateStatus по продуктовым нуждам +5. P2–P3: DX и полировка фоном ## Закрыто -- Модель ошибок + guards (`HttpResponseError`, `ParseError`, `cause`/`config`) -- `TimeoutError.timeout`, `ParseError.responseType` / `raw` -- Body: FormData / URLSearchParams / Blob / ArrayBufferView (`prepareRequestBody`) -- Default `params`, number/boolean params, null-skip -- Options/config validation, `RequestPreparationError` -- formData / stream response types -- `FetchAdapter`: `ParseError`, если streaming body отсутствует (`response.body === null`) -- `HttpResponseError` передаёт `config` в базовый `HttpClientError` -- Default `FetchAdapter`, `withCredentials` (client / builder / оба адаптера) -- Adapter options (`FetchAdapterOptions`, `XhrAdapterOptions`) -- Удалены устаревшие закомментированные методы из `HttpRequestBuilder` -- Publish hygiene: `files: ["dist"]`, `.npmignore` +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. formData / stream response types +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` From 5980ea2ab50cd113dcd105b651cc2f3fd19c6816 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Fri, 21 Aug 2026 14:53:35 +0500 Subject: [PATCH 45/60] feat(http): merge headers --- services/http-client/TODO.md | 11 ++++------- services/http-client/src/core/HttpClient.ts | 2 +- .../test/core/HttpClient.defaultConfig.tests.ts | 15 ++++++++++++++- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/services/http-client/TODO.md b/services/http-client/TODO.md index f6dfcde5..480711ed 100644 --- a/services/http-client/TODO.md +++ b/services/http-client/TODO.md @@ -18,15 +18,11 @@ `responseType('blob').execute()` возвращает `unknown` — каждый вызов требует явного дженерика, а рассинхрон `responseType('blob')` + `execute()` не ловится компилятором. Нужен `THttpResponseData` + дженерик-builder (`HttpRequestBuilder`), покрыть type-тестами. Сделать до README, чтобы документировать финальный API. -### P1-3 — Клонировать default `headers` в конструкторе клиента - -`params` копируются через `mergeParams`, а `headers` сохраняются по ссылке — внешняя мутация объекта влияет на клиент. Нужно `headers: mergeHeaders(options.headers)` (или аналог) и тест на иммутабельность. - -### P1-4 — Выровнять parity Fetch ↔ XHR +### P1-3 — Выровнять parity Fetch ↔ XHR XHR не учитывает `Content-Length: 0` (Fetch уже учитывает). `getErrorData` читает тело ошибки только при `responseType === 'text'`, поэтому 4xx/5xx при `blob`/`formData` почти всегда без `data`. Выровнять семантику empty body и error body + тесты на оба адаптера. Смежное ограничение XHR: парсинг `formData` построен на глобальном `Response` — в окружениях без fetch-API падает без fail-fast; нужен guard с `RequestPreparationError` или явная документация. -### P1-5 — Уточнить валидацию опций `FetchAdapter` +### P1-4 — Уточнить валидацию опций `FetchAdapter` Нужно отклонять значения и сочетания, которые Fetch не поддерживает при программном запросе: например, `mode: 'navigate'` и `cache: 'only-if-cached'` без `mode: 'same-origin'`. Ошибка должна возникать при создании адаптера, а не во время отправки запроса. Нужны также тесты на эти комбинации. @@ -85,7 +81,7 @@ XHR не учитывает `Content-Length: 0` (Fetch уже учитывает ## Рекомендуемый порядок 1. P0: README + changeset (после P1 «вывод типа данных ответа», чтобы документировать финальный API) -2. P1: typed errors + headers clone + adapter parity + Fetch options validation +2. P1: typed errors + adapter parity + Fetch options validation 3. P2: package.json модернизация вместе с релизной подготовкой 4. P2: retry / progress / validateStatus по продуктовым нуждам 5. P2–P3: DX и полировка фоном @@ -104,3 +100,4 @@ XHR не учитывает `Content-Length: 0` (Fetch уже учитывает 10. Adapter options (`FetchAdapterOptions`, `XhrAdapterOptions`) 11. Удалены устаревшие закомментированные методы из `HttpRequestBuilder` 12. Publish hygiene: `files: ["dist"]`, `.npmignore` +13. Default `headers` клонируются в конструкторе `HttpClient`. diff --git a/services/http-client/src/core/HttpClient.ts b/services/http-client/src/core/HttpClient.ts index e74216c2..2c42d2ea 100644 --- a/services/http-client/src/core/HttpClient.ts +++ b/services/http-client/src/core/HttpClient.ts @@ -38,7 +38,7 @@ export class HttpClient { this.defaultConfig = { baseUrl: options.baseUrl, - headers: options.headers, + headers: mergeHeaders(options.headers), params: mergeParams(options.params), timeout: options.timeout, withCredentials: options.withCredentials diff --git a/services/http-client/test/core/HttpClient.defaultConfig.tests.ts b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts index 822111a4..d97ca223 100644 --- a/services/http-client/test/core/HttpClient.defaultConfig.tests.ts +++ b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts @@ -4,7 +4,7 @@ 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 } from '../../src/types'; +import { IHttpClientAdapter, THttpHeaders } from '../../src/types'; import { handlers } from '../__handlers__/HttpClient.defaultConfig.handlers'; import { BASE_URL } from '../__fixtures__'; @@ -101,6 +101,19 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { 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').execute<{ def: string | null; custom: string | null }>(); + + 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').execute<{ ok: boolean }>(); From 054e94327e9758d8b8b5ff06cde8a61c60920f03 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Fri, 21 Aug 2026 15:01:41 +0500 Subject: [PATCH 46/60] feat(http): translate comments --- services/http-client/DECISIONS.md | 25 ++++++++++++++++++- services/http-client/TODO.md | 11 +++----- .../http-client/src/adapters/XhrAdapter.ts | 2 +- services/http-client/src/core/HttpClient.ts | 8 +++--- .../http-client/src/errors/HttpClientError.ts | 2 +- services/http-client/src/types/HttpHooks.ts | 22 ++++++++-------- 6 files changed, 45 insertions(+), 25 deletions(-) diff --git a/services/http-client/DECISIONS.md b/services/http-client/DECISIONS.md index 04840de2..5bf370bf 100644 --- a/services/http-client/DECISIONS.md +++ b/services/http-client/DECISIONS.md @@ -4,7 +4,7 @@ ## D-001 — Настройки, специфичные для адаптера -Статус: принято +Статус: принято Дата: 2026-08-21 ### Решение @@ -25,3 +25,26 @@ Fetch- и XHR-специфичные параметры передаются в ### Когда пересматривать При добавлении третьего транспорта или при подтверждённой необходимости задавать Fetch-специфичные параметры для одного запроса. + +## D-002 — Языковое соглашение + +Статус: принято +Дата: 2026-08-21 + +### Решение + +Комментарии и JSDoc в исходном коде пакета пишутся на английском языке. Текст пользовательской документации пакета — `README.md`, `TODO.md` и `DECISIONS.md` — пишется на русском языке. + +### Причина + +Англоязычные комментарии и JSDoc составляют публичный интерфейс npm-пакета и понятны международной аудитории. Планирование и архитектурный контекст ведутся на русском языке, на котором работает команда. + +### Последствия + +- В комментариях и JSDoc исходников не используется русский текст. +- В пользовательской документации не используется английский связный текст; идентификаторы, имена API, значения и фрагменты кода сохраняют исходное написание. +- Новый `README.md` из задачи P0-1 должен быть написан на русском языке. + +### Когда пересматривать + +При изменении основной аудитории документации или языка работы команды. diff --git a/services/http-client/TODO.md b/services/http-client/TODO.md index 480711ed..e96d9758 100644 --- a/services/http-client/TODO.md +++ b/services/http-client/TODO.md @@ -62,19 +62,15 @@ XHR не учитывает `Content-Length: 0` (Fetch уже учитывает Сейчас на каждую фазу можно назначить лишь один хук, а следующий вызов заменяет предыдущий. При совместном использовании auth, tracing и логирования потребуется композиция хуков с предсказуемым порядком и правилами восстановления. -### P3-2 — Единый язык public surface - -Свести JSDoc и сообщения ошибок к одному языку (EN для npm API); убрать RU/EN mix во внутренних комментариях публичных модулей. - -### P3-3 — Не класть пустой объект `params` в merged config +### P3-2 — Не класть пустой объект `params` в merged config `mergeParams` всегда возвращает `{}`. Если источников нет или после merge ключей не осталось — лучше `undefined`, чтобы не шуметь в config/логах. -### P3-4 — Convenience-методы builder +### P3-3 — Convenience-методы builder Опциональный сахар: `json()`, `acceptJson()`, `multipart()` поверх headers. -### P3-5 — Чистка конфигурации +### P3-4 — Чистка конфигурации `.npmignore` избыточен при `files: ["dist"]` — удалить. Поправить команду запуска тестов пакета в корневом AGENTS.md (`vitest run --root ../../ --project ` не работает; рабочий вариант — `npm test -w @byndyusoft-ui/http-client`). @@ -101,3 +97,4 @@ XHR не учитывает `Content-Length: 0` (Fetch уже учитывает 11. Удалены устаревшие закомментированные методы из `HttpRequestBuilder` 12. Publish hygiene: `files: ["dist"]`, `.npmignore` 13. Default `headers` клонируются в конструкторе `HttpClient`. +14. Языковое соглашение: комментарии и JSDoc исходников — английский; пользовательские документы пакета — русский (D-002). diff --git a/services/http-client/src/adapters/XhrAdapter.ts b/services/http-client/src/adapters/XhrAdapter.ts index 3a34f7ac..c28b05bb 100644 --- a/services/http-client/src/adapters/XhrAdapter.ts +++ b/services/http-client/src/adapters/XhrAdapter.ts @@ -151,7 +151,7 @@ interface IPreparedXhrRequest { function setResponseType(xhr: XMLHttpRequest, responseType: THttpResponseType | undefined): void { if (responseType === HTTP_RESPONSE_TYPES.ARRAY_BUFFER) { - // XHR принимает только DOM-значение в нижнем регистре + // XHR accepts only a lowercase DOM value. xhr.responseType = 'arraybuffer'; } else if (responseType === HTTP_RESPONSE_TYPES.BLOB || responseType === HTTP_RESPONSE_TYPES.FORM_DATA) { xhr.responseType = 'blob'; diff --git a/services/http-client/src/core/HttpClient.ts b/services/http-client/src/core/HttpClient.ts index 2c42d2ea..87428188 100644 --- a/services/http-client/src/core/HttpClient.ts +++ b/services/http-client/src/core/HttpClient.ts @@ -45,28 +45,28 @@ export class HttpClient { }; } - /** Устанавливает хук onRequest, заменяя заданный через опции конструктора. Возвращает клиент для чейнинга. */ + /** 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; } - /** Устанавливает хук onRequestError, заменяя заданный через опции конструктора. Возвращает клиент для чейнинга. */ + /** 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; } - /** Устанавливает хук onResponse, заменяя заданный через опции конструктора. Возвращает клиент для чейнинга. */ + /** 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; } - /** Устанавливает хук onResponseError, заменяя заданный через опции конструктора. Возвращает клиент для чейнинга. */ + /** Sets the onResponseError hook, replacing the hook configured through constructor options. Returns this client for chaining. */ public onResponseError(hook: THttpResponseErrorHook): this { this.onResponseErrorHook = hook; diff --git a/services/http-client/src/errors/HttpClientError.ts b/services/http-client/src/errors/HttpClientError.ts index ba1499eb..39edaa29 100644 --- a/services/http-client/src/errors/HttpClientError.ts +++ b/services/http-client/src/errors/HttpClientError.ts @@ -4,7 +4,7 @@ export interface IHttpClientErrorOptions extends ErrorOptions { config?: IHttpRequestConfig; } -/** Базовый класс всех ошибок http-клиента. Позволяет поймать любую ошибку клиента одной проверкой instanceof. */ +/** 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; diff --git a/services/http-client/src/types/HttpHooks.ts b/services/http-client/src/types/HttpHooks.ts index 110c630c..f6d7cb5d 100644 --- a/services/http-client/src/types/HttpHooks.ts +++ b/services/http-client/src/types/HttpHooks.ts @@ -1,35 +1,35 @@ /* eslint-disable @typescript-eslint/no-invalid-void-type -- - * void здесь намеренен: хуки ошибок могут ничего не возвращать, - * и только void принимает функцию без return вроде `(error) => { log(error); }`. */ + * 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'; /** - * Вызывается один раз на запрос после слияния с дефолтным конфигом клиента, - * непосредственно перед вызовом адаптера. - * Возвращённый конфиг продолжает путь по pipeline. Брошенная ошибка уходит в onRequestError. + * 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; -/** Вызывается, только если хук onRequest бросил исключение или отклонил промис. */ +/** Called only when onRequest throws or rejects. */ export type THttpRequestErrorHook = ( error: unknown ) => THttpRequestErrorHookResult | Promise; /** - * Вызывается один раз на запрос, когда адаптер успешно зарезолвился (2xx). - * Возвращённый ответ уходит вызывающему коду. Брошенная ошибка уходит в onResponseError. + * 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; -/** Ответ для восстановления (onResponse на нём повторно НЕ вызывается), или ничего — тогда перевыбрасывается исходная ошибка. */ +/** 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; /** - * Вызывается, когда адаптер отклонил промис или когда хук onResponse бросил ошибку. + * Called when the adapter rejects or when onResponse throws. */ export type THttpResponseErrorHook = ( error: unknown From fb6392506ef2f5b7000da554aeb7b2eef2098f97 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Fri, 21 Aug 2026 15:10:32 +0500 Subject: [PATCH 47/60] feat(http): fix fetch adapter --- services/http-client/TODO.md | 7 ++----- .../asserts/assertValidFetchAdapterOptions.ts | 6 +++++- .../src/types/FetchAdapterOptions.ts | 5 ++++- .../test/adapters/AdapterOptions.tests.ts | 20 ++++++++++++++++--- .../test/types/AdapterOptions.tests-d.ts | 1 + 5 files changed, 29 insertions(+), 10 deletions(-) diff --git a/services/http-client/TODO.md b/services/http-client/TODO.md index e96d9758..75e39f24 100644 --- a/services/http-client/TODO.md +++ b/services/http-client/TODO.md @@ -22,10 +22,6 @@ XHR не учитывает `Content-Length: 0` (Fetch уже учитывает). `getErrorData` читает тело ошибки только при `responseType === 'text'`, поэтому 4xx/5xx при `blob`/`formData` почти всегда без `data`. Выровнять семантику empty body и error body + тесты на оба адаптера. Смежное ограничение XHR: парсинг `formData` построен на глобальном `Response` — в окружениях без fetch-API падает без fail-fast; нужен guard с `RequestPreparationError` или явная документация. -### P1-4 — Уточнить валидацию опций `FetchAdapter` - -Нужно отклонять значения и сочетания, которые Fetch не поддерживает при программном запросе: например, `mode: 'navigate'` и `cache: 'only-if-cached'` без `mode: 'same-origin'`. Ошибка должна возникать при создании адаптера, а не во время отправки запроса. Нужны также тесты на эти комбинации. - ## P2 ### P2-1 — Модернизация package.json @@ -77,7 +73,7 @@ XHR не учитывает `Content-Length: 0` (Fetch уже учитывает ## Рекомендуемый порядок 1. P0: README + changeset (после P1 «вывод типа данных ответа», чтобы документировать финальный API) -2. P1: typed errors + adapter parity + Fetch options validation +2. P1: typed errors + adapter parity 3. P2: package.json модернизация вместе с релизной подготовкой 4. P2: retry / progress / validateStatus по продуктовым нуждам 5. P2–P3: DX и полировка фоном @@ -98,3 +94,4 @@ XHR не учитывает `Content-Length: 0` (Fetch уже учитывает 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'`. diff --git a/services/http-client/src/asserts/assertValidFetchAdapterOptions.ts b/services/http-client/src/asserts/assertValidFetchAdapterOptions.ts index a1d3f8a4..aab53c57 100644 --- a/services/http-client/src/asserts/assertValidFetchAdapterOptions.ts +++ b/services/http-client/src/asserts/assertValidFetchAdapterOptions.ts @@ -5,7 +5,7 @@ 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', 'navigate', 'no-cors', 'same-origin']; +const FETCH_MODE_VALUES = ['cors', 'no-cors', 'same-origin']; const FETCH_REDIRECT_VALUES = ['error', 'follow', 'manual']; const FETCH_REFERRER_POLICY_VALUES = [ '', @@ -63,6 +63,10 @@ export function assertValidFetchAdapterOptions(options: unknown): asserts option 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'); } diff --git a/services/http-client/src/types/FetchAdapterOptions.ts b/services/http-client/src/types/FetchAdapterOptions.ts index e90dc99d..1531b3cd 100644 --- a/services/http-client/src/types/FetchAdapterOptions.ts +++ b/services/http-client/src/types/FetchAdapterOptions.ts @@ -1,10 +1,13 @@ +/** 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?: RequestMode; + mode?: TFetchAdapterMode; redirect?: RequestRedirect; referrer?: string; referrerPolicy?: ReferrerPolicy; diff --git a/services/http-client/test/adapters/AdapterOptions.tests.ts b/services/http-client/test/adapters/AdapterOptions.tests.ts index 540bd6e0..3d00f943 100644 --- a/services/http-client/test/adapters/AdapterOptions.tests.ts +++ b/services/http-client/test/adapters/AdapterOptions.tests.ts @@ -3,7 +3,7 @@ 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 { TRequestBuilderErrorCode } from '../../src/types'; +import { IFetchAdapterOptions, TRequestBuilderErrorCode } from '../../src/types'; function createMockXhr(response: unknown = 'response'): { xhr: XMLHttpRequest; @@ -103,6 +103,10 @@ describe('adapter constructor options', () => { } }); + 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( @@ -175,8 +179,18 @@ describe('adapter constructor options', () => { code: REQUEST_BUILDER_ERROR_CODES.INVALID_FETCH_ADAPTER_OPTIONS }, { - name: 'Fetch mode', - action: () => new FetchAdapter({ mode: 'unsupported' as RequestMode }), + 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 }, { diff --git a/services/http-client/test/types/AdapterOptions.tests-d.ts b/services/http-client/test/types/AdapterOptions.tests-d.ts index d5bdd710..43194a27 100644 --- a/services/http-client/test/types/AdapterOptions.tests-d.ts +++ b/services/http-client/test/types/AdapterOptions.tests-d.ts @@ -21,5 +21,6 @@ describe('adapter option types', () => { expectTypeOf(new FetchAdapter(fetchOptions)).toEqualTypeOf(); expectTypeOf(new XhrAdapter(xhrOptions)).toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf<'cors' | 'no-cors' | 'same-origin'>(); }); }); From f7ad1a353d6f04dd6301a6ebbd7a6703fc4a049c Mon Sep 17 00:00:00 2001 From: abrosimov Date: Fri, 21 Aug 2026 15:18:37 +0500 Subject: [PATCH 48/60] feat(http): fix issue --- services/http-client/TODO.md | 7 +- .../http-client/src/adapters/XhrAdapter.ts | 78 ++++++++++++++++--- .../__handlers__/HttpClient.GET.handlers.ts | 4 + .../test/adapters/AdapterOptions.tests.ts | 22 +++++- .../test/core/HttpClient.GET.tests.ts | 38 +++++++-- 5 files changed, 129 insertions(+), 20 deletions(-) diff --git a/services/http-client/TODO.md b/services/http-client/TODO.md index 75e39f24..0cc67e8b 100644 --- a/services/http-client/TODO.md +++ b/services/http-client/TODO.md @@ -18,10 +18,6 @@ `responseType('blob').execute()` возвращает `unknown` — каждый вызов требует явного дженерика, а рассинхрон `responseType('blob')` + `execute()` не ловится компилятором. Нужен `THttpResponseData` + дженерик-builder (`HttpRequestBuilder`), покрыть type-тестами. Сделать до README, чтобы документировать финальный API. -### P1-3 — Выровнять parity Fetch ↔ XHR - -XHR не учитывает `Content-Length: 0` (Fetch уже учитывает). `getErrorData` читает тело ошибки только при `responseType === 'text'`, поэтому 4xx/5xx при `blob`/`formData` почти всегда без `data`. Выровнять семантику empty body и error body + тесты на оба адаптера. Смежное ограничение XHR: парсинг `formData` построен на глобальном `Response` — в окружениях без fetch-API падает без fail-fast; нужен guard с `RequestPreparationError` или явная документация. - ## P2 ### P2-1 — Модернизация package.json @@ -73,7 +69,7 @@ XHR не учитывает `Content-Length: 0` (Fetch уже учитывает ## Рекомендуемый порядок 1. P0: README + changeset (после P1 «вывод типа данных ответа», чтобы документировать финальный API) -2. P1: typed errors + adapter parity +2. P1: typed errors 3. P2: package.json модернизация вместе с релизной подготовкой 4. P2: retry / progress / validateStatus по продуктовым нуждам 5. P2–P3: DX и полировка фоном @@ -95,3 +91,4 @@ XHR не учитывает `Content-Length: 0` (Fetch уже учитывает 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`/`formData` и fail-fast для недоступного `Response.formData`. diff --git a/services/http-client/src/adapters/XhrAdapter.ts b/services/http-client/src/adapters/XhrAdapter.ts index c28b05bb..91cf84df 100644 --- a/services/http-client/src/adapters/XhrAdapter.ts +++ b/services/http-client/src/adapters/XhrAdapter.ts @@ -92,12 +92,39 @@ async function readBlob(blob: Blob): Promise { }); } +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'; +} + async function getResponseBody( xhr: XMLHttpRequest, config: IHttpRequestConfig, responseType?: THttpResponseType ): Promise { - if (xhr.status === 204) { + if (hasEmptyResponseBody(xhr)) { return undefined as T; } @@ -169,6 +196,18 @@ function assertStreamingSupported(responseType: THttpResponseType | undefined, c } } +function assertFormDataResponseSupported( + responseType: THttpResponseType | undefined, + config: IHttpRequestConfig +): void { + if ( + responseType === HTTP_RESPONSE_TYPES.FORM_DATA && + (typeof Response === 'undefined' || typeof Response.prototype.formData !== 'function') + ) { + throw new RequestPreparationError('FormData responses are not supported in this environment', { config }); + } +} + function createResponse(xhr: XMLHttpRequest, config: IHttpRequestConfig, data: T): IHttpResponse { return { data, @@ -179,19 +218,39 @@ function createResponse(xhr: XMLHttpRequest, config: IHttpRequestConfig, data }; } -function getErrorData(xhr: XMLHttpRequest): unknown { - try { - if (xhr.responseType !== 'text') { +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; } - const text = xhr.responseText; + 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); + return JSON.parse(text) as unknown; } catch { return text; } @@ -200,14 +259,14 @@ function getErrorData(xhr: XMLHttpRequest): unknown { } } -function createResponseError(xhr: XMLHttpRequest, config: IHttpRequestConfig): HttpResponseError { +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, - getErrorData(xhr) + await getErrorData(xhr) ); } @@ -243,6 +302,7 @@ function prepareXhrRequest(config: IHttpRequestConfig, options: IXhrAdapterOptio } assertStreamingSupported(responseType, config); + assertFormDataResponseSupported(responseType, config); const xhr = new XMLHttpRequest(); xhr.open(method, fullUrl, true); @@ -326,7 +386,7 @@ function configureXhrEventHandlers( request.cleanup(); if (!isSuccessful()) { - reject(createResponseError(xhr, config)); + reject(await createResponseError(xhr, config)); return; } diff --git a/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts index 273d450f..977eccc7 100644 --- a/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts +++ b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts @@ -33,6 +33,10 @@ export const handlers = [ 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' } }); diff --git a/services/http-client/test/adapters/AdapterOptions.tests.ts b/services/http-client/test/adapters/AdapterOptions.tests.ts index 3d00f943..5d44333b 100644 --- a/services/http-client/test/adapters/AdapterOptions.tests.ts +++ b/services/http-client/test/adapters/AdapterOptions.tests.ts @@ -2,7 +2,7 @@ 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 { RequestBuilderError, RequestPreparationError } from '../../src/errors'; import { IFetchAdapterOptions, TRequestBuilderErrorCode } from '../../src/types'; function createMockXhr(response: unknown = 'response'): { @@ -172,6 +172,26 @@ describe('adapter constructor options', () => { } }); + test('rejects XHR FormData responses when Response.formData is unavailable', async () => { + const mockXhr = createMockXhr(); + const xhrFactory = vi.fn(() => mockXhr.xhr); + vi.stubGlobal('XMLHttpRequest', xhrFactory); + vi.stubGlobal('Response', undefined); + + try { + await expect( + new HttpClient({ adapter: new XhrAdapter() }) + .get('https://api.example.test/items') + .responseType(HTTP_RESPONSE_TYPES.FORM_DATA) + .execute() + ).rejects.toBeInstanceOf(RequestPreparationError); + + expect(xhrFactory).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + test.each([ { name: 'Fetch options object', diff --git a/services/http-client/test/core/HttpClient.GET.tests.ts b/services/http-client/test/core/HttpClient.GET.tests.ts index 738e5595..9cb14f82 100644 --- a/services/http-client/test/core/HttpClient.GET.tests.ts +++ b/services/http-client/test/core/HttpClient.GET.tests.ts @@ -68,7 +68,13 @@ describe.each(adapters)('HttpClient.$name — GET', ({ name, create }) => { const response = await client .get('/users') .params({ page: 2, active: null, source: undefined, value: [1, null, undefined, 2] }) - .execute<{ page: string | null; active: string | null; source: string | null; value: string[]; keys: string[] }>(); + .execute<{ + page: string | null; + active: string | null; + source: string | null; + value: string[]; + keys: string[]; + }>(); expect(response.data).toMatchObject({ page: '2', @@ -127,6 +133,13 @@ describe.each(adapters)('HttpClient.$name — GET', ({ name, create }) => { 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 @@ -170,15 +183,15 @@ describe.each(adapters)('HttpClient.$name — GET', ({ name, create }) => { vi.stubGlobal( 'fetch', - vi.fn(async () => { - return { + vi.fn(() => + Promise.resolve({ ok: true, status: 200, statusText: 'OK', headers: new Headers(), body: null - } as unknown as Response; - }) + } as unknown as Response) + ) ); try { @@ -213,6 +226,21 @@ describe.each(adapters)('HttpClient.$name — GET', ({ name, create }) => { } }); + test.each([HTTP_RESPONSE_TYPES.BLOB, HTTP_RESPONSE_TYPES.FORM_DATA])( + 'parses error data with %s responseType', + async responseType => { + const client = createClient(); + + try { + await client.get('/not-found').responseType(responseType).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(); From cb518e30515c3ad54fc2e02eb9e98a392d5132b2 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Fri, 21 Aug 2026 19:39:16 +0500 Subject: [PATCH 49/60] feat(http): wip --- services/http-client/DECISIONS.md | 98 +++++++++++++++++++++++++++++++ services/http-client/TODO.md | 4 +- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/services/http-client/DECISIONS.md b/services/http-client/DECISIONS.md index 5bf370bf..ab033656 100644 --- a/services/http-client/DECISIONS.md +++ b/services/http-client/DECISIONS.md @@ -48,3 +48,101 @@ Fetch- и XHR-специфичные параметры передаются в ### Когда пересматривать При изменении основной аудитории документации или языка работы команды. + +## 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).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 с пользовательским валидатором данных. diff --git a/services/http-client/TODO.md b/services/http-client/TODO.md index 0cc67e8b..e12350e5 100644 --- a/services/http-client/TODO.md +++ b/services/http-client/TODO.md @@ -12,11 +12,11 @@ ### P1-1 — Типизировать тело HTTP-ошибки -`HttpResponseError` уже параметризован, но адаптеры создают его с `unknown`, а guard не позволяет вызывающему коду указать ожидаемый тип. Нужен явный сценарий для `400` и `422`, например `isHttpResponseError(error)`, плюс type-tests. +`HttpResponseError` уже параметризован, но адаптеры создают его с `unknown`, а guard не позволяет вызывающему коду указать ожидаемый тип. Вариант с доверенным generic-guard, например `isHttpResponseError(error)`, зафиксирован в D-004 со статусом «не принято». После выбора контракта нужны явные сценарии для `400` и `422` и type-тесты. ### P1-2 — Вывод типа данных ответа из `responseType` -`responseType('blob').execute()` возвращает `unknown` — каждый вызов требует явного дженерика, а рассинхрон `responseType('blob')` + `execute()` не ловится компилятором. Нужен `THttpResponseData` + дженерик-builder (`HttpRequestBuilder`), покрыть type-тестами. Сделать до README, чтобы документировать финальный API. +`responseType('blob').execute()` возвращает `unknown`, а рассинхрон `responseType('blob')` + `execute()` не ловится компилятором. Вариант, при котором JSON сохраняет явный `execute()`, а `text`, `blob`, `arrayBuffer`, `formData` и `stream` получают тип из `responseType`, зафиксирован в D-003 со статусом «не принято». После выбора контракта понадобятся type-тесты. Сделать до README, чтобы документировать финальный API. ## P2 From e985ff1e72208d3b813fb9d8aab737efc13942b5 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Sat, 22 Aug 2026 21:59:12 +0500 Subject: [PATCH 50/60] feat(http): fix issue --- services/http-client/DECISIONS.md | 59 ++++++++++++++----- services/http-client/TODO.md | 11 ++-- .../http-client/src/utilities/mergeParams.ts | 9 ++- .../core/HttpClient.defaultConfig.tests.ts | 23 +++++++- .../test/core/HttpRequestBuilder.tests.ts | 9 +++ 5 files changed, 84 insertions(+), 27 deletions(-) diff --git a/services/http-client/DECISIONS.md b/services/http-client/DECISIONS.md index ab033656..56e92a66 100644 --- a/services/http-client/DECISIONS.md +++ b/services/http-client/DECISIONS.md @@ -17,10 +17,10 @@ Fetch- и XHR-специфичные параметры передаются в ### Последствия -- `withCredentials` остаётся общей настройкой `HttpClient` и отдельного запроса, поскольку поддерживается Fetch и XHR. -- `IFetchAdapterOptions` и `IXhrAdapterOptions` являются публичными контрактами настроек адаптеров. -- Конфигурация запроса и `HttpClient` имеет приоритет над настройками XHR-адаптера для общих полей: `timeout`, `responseType`, `withCredentials`. -- `withCredentials` запроса имеет приоритет над `credentials` в `FetchAdapter`. +- `withCredentials` остаётся общей настройкой `HttpClient` и отдельного запроса, поскольку поддерживается Fetch и XHR. +- `IFetchAdapterOptions` и `IXhrAdapterOptions` являются публичными контрактами настроек адаптеров. +- Конфигурация запроса и `HttpClient` имеет приоритет над настройками XHR-адаптера для общих полей: `timeout`, `responseType`, `withCredentials`. +- `withCredentials` запроса имеет приоритет над `credentials` в `FetchAdapter`. ### Когда пересматривать @@ -41,9 +41,9 @@ Fetch- и XHR-специфичные параметры передаются в ### Последствия -- В комментариях и JSDoc исходников не используется русский текст. -- В пользовательской документации не используется английский связный текст; идентификаторы, имена API, значения и фрагменты кода сохраняют исходное написание. -- Новый `README.md` из задачи P0-1 должен быть написан на русском языке. +- В комментариях и JSDoc исходников не используется русский текст. +- В пользовательской документации не используется английский связный текст; идентификаторы, имена API, значения и фрагменты кода сохраняют исходное написание. +- Новый `README.md` из задачи P0-1 должен быть написан на русском языке. ### Когда пересматривать @@ -88,10 +88,10 @@ httpClient.get('/status').responseType('text').execute(); // Promise()`; для прочих поддерживаемых типов итоговый тип задаётся `responseType`. +- `HttpRequestBuilder` будет параметризован выбранным `responseType` только на уровне TypeScript. +- Fluent-методы должны сохранять параметр response type. +- Runtime-конфигурация запросов и адаптеры не изменятся. +- Для JSON сохраняется явный `execute()`; для прочих поддерживаемых типов итоговый тип задаётся `responseType`. ### Когда пересматривать @@ -137,12 +137,39 @@ Fetch- и XHR-адаптеры могут разобрать тело ошибк ### Последствия -- Адаптеры продолжат создавать `HttpResponseError`; их runtime-поведение не изменится. -- Generic-параметр guard будет доверенным TypeScript-сужением и не станет проверять структуру `data` во время выполнения. -- `data` после сужения сохранит возможность отсутствия: `T | undefined`. -- Проверка `instanceof` останется единственным runtime-критерием принадлежности к `HttpResponseError`. -- Понадобятся type-тесты для вызовов guard с generic-параметром и без него, а также runtime-тест ответа `400` или `422` с JSON-телом. +- Адаптеры продолжат создавать `HttpResponseError`; их runtime-поведение не изменится. +- Generic-параметр guard будет доверенным TypeScript-сужением и не станет проверять структуру `data` во время выполнения. +- `data` после сужения сохранит возможность отсутствия: `T | undefined`. +- Проверка `instanceof` останется единственным runtime-критерием принадлежности к `HttpResponseError`. +- Понадобятся type-тесты для вызовов guard с generic-параметром и без него, а также runtime-тест ответа `400` или `422` с JSON-телом. ### Когда пересматривать При необходимости гарантировать схему тела ошибки во время выполнения, поддержать разные схемы для разных статусов или перейти к API с пользовательским валидатором данных. + +## 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` и адаптеров. \ No newline at end of file diff --git a/services/http-client/TODO.md b/services/http-client/TODO.md index e12350e5..ebae809b 100644 --- a/services/http-client/TODO.md +++ b/services/http-client/TODO.md @@ -34,7 +34,7 @@ ### P2-4 — Спроектировать opt-in retry -Добавить повторы для временных сетевых сбоев, `408`, `429` и части `5xx`: с лимитом попыток, exponential backoff, поддержкой `Retry-After`, отменой через `AbortSignal` и безопасным поведением для мутаций. Предпочтительно middleware/hook, а не жёсткая логика в core. +Согласно D-005, повторы будут реализованы в отдельном opt-in классе для оркестрации запросов, без встраивания retry-логики в `HttpClient`, адаптеры или hooks. Нужно спроектировать лимит попыток, exponential backoff, поддержку `Retry-After`, отмену через `AbortSignal`, перечень временных сетевых сбоев и статусов (`408`, `429`, часть `5xx`), а также безопасное поведение для мутаций. ### P2-5 — Юнит-тесты утилит @@ -54,15 +54,11 @@ Сейчас на каждую фазу можно назначить лишь один хук, а следующий вызов заменяет предыдущий. При совместном использовании auth, tracing и логирования потребуется композиция хуков с предсказуемым порядком и правилами восстановления. -### P3-2 — Не класть пустой объект `params` в merged config - -`mergeParams` всегда возвращает `{}`. Если источников нет или после merge ключей не осталось — лучше `undefined`, чтобы не шуметь в config/логах. - -### P3-3 — Convenience-методы builder +### P3-2 — Convenience-методы builder Опциональный сахар: `json()`, `acceptJson()`, `multipart()` поверх headers. -### P3-4 — Чистка конфигурации +### P3-3 — Чистка конфигурации `.npmignore` избыточен при `files: ["dist"]` — удалить. Поправить команду запуска тестов пакета в корневом AGENTS.md (`vitest run --root ../../ --project ` не работает; рабочий вариант — `npm test -w @byndyusoft-ui/http-client`). @@ -92,3 +88,4 @@ 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`/`formData` и fail-fast для недоступного `Response.formData`. +17. Пустые `params` нормализуются в `undefined` вместо `{}`. diff --git a/services/http-client/src/utilities/mergeParams.ts b/services/http-client/src/utilities/mergeParams.ts index 8963644d..748f08ac 100644 --- a/services/http-client/src/utilities/mergeParams.ts +++ b/services/http-client/src/utilities/mergeParams.ts @@ -4,8 +4,11 @@ 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. */ -export function mergeParams(...sources: Array): THttpParams { +/** + * 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) { @@ -35,5 +38,5 @@ export function mergeParams(...sources: Array): THttpPa } } - return result; + return Object.keys(result).length === 0 ? undefined : result; } diff --git a/services/http-client/test/core/HttpClient.defaultConfig.tests.ts b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts index d97ca223..3f47fd73 100644 --- a/services/http-client/test/core/HttpClient.defaultConfig.tests.ts +++ b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts @@ -4,7 +4,7 @@ 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, THttpHeaders } from '../../src/types'; +import { IHttpClientAdapter, IHttpRequestConfig, THttpHeaders } from '../../src/types'; import { handlers } from '../__handlers__/HttpClient.defaultConfig.handlers'; import { BASE_URL } from '../__fixtures__'; @@ -89,6 +89,27 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { 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 }); diff --git a/services/http-client/test/core/HttpRequestBuilder.tests.ts b/services/http-client/test/core/HttpRequestBuilder.tests.ts index 47d3fda8..31ea0332 100644 --- a/services/http-client/test/core/HttpRequestBuilder.tests.ts +++ b/services/http-client/test/core/HttpRequestBuilder.tests.ts @@ -246,6 +246,15 @@ describe('HttpRequestBuilder', () => { 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); }); From f0554c57859d1056caaa26bae07ca11135a73ee9 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Sat, 22 Aug 2026 22:27:20 +0500 Subject: [PATCH 51/60] feat(http): fix issues --- services/http-client/DECISIONS.md | 71 +++++++++++++------ services/http-client/TODO.md | 12 ++-- ...ssertBodyAllowed.ts => assertValidBody.ts} | 7 +- .../src/asserts/assertValidRequestConfig.ts | 6 +- services/http-client/src/asserts/index.ts | 2 +- .../src/core/HttpRequestBuilder.ts | 4 +- .../http-client/src/utilities/buildUrl.ts | 5 +- .../src/utilities/getErrorMessage.ts | 1 + .../http-client/src/utilities/mergeHeaders.ts | 2 + .../src/utilities/prepareRequestBody.ts | 7 ++ .../test/core/HttpClient.POST.tests.ts | 10 +++ .../test/core/HttpClient.hooks.tests.ts | 15 ++++ .../test/core/HttpRequestBuilder.tests.ts | 12 ++++ .../test/utilities/buildUrl.tests.ts | 34 +++++++++ .../test/utilities/getErrorMessage.tests.ts | 11 +++ .../test/utilities/mergeHeaders.tests.ts | 35 +++++++++ .../test/utilities/mergeParams.tests.ts | 28 ++++++++ .../utilities/prepareRequestBody.tests.ts | 58 +++++++++++++++ 18 files changed, 283 insertions(+), 37 deletions(-) rename services/http-client/src/asserts/{assertBodyAllowed.ts => assertValidBody.ts} (55%) create mode 100644 services/http-client/test/utilities/buildUrl.tests.ts create mode 100644 services/http-client/test/utilities/getErrorMessage.tests.ts create mode 100644 services/http-client/test/utilities/mergeHeaders.tests.ts create mode 100644 services/http-client/test/utilities/mergeParams.tests.ts create mode 100644 services/http-client/test/utilities/prepareRequestBody.tests.ts diff --git a/services/http-client/DECISIONS.md b/services/http-client/DECISIONS.md index 56e92a66..f9e8c402 100644 --- a/services/http-client/DECISIONS.md +++ b/services/http-client/DECISIONS.md @@ -17,10 +17,10 @@ Fetch- и XHR-специфичные параметры передаются в ### Последствия -- `withCredentials` остаётся общей настройкой `HttpClient` и отдельного запроса, поскольку поддерживается Fetch и XHR. -- `IFetchAdapterOptions` и `IXhrAdapterOptions` являются публичными контрактами настроек адаптеров. -- Конфигурация запроса и `HttpClient` имеет приоритет над настройками XHR-адаптера для общих полей: `timeout`, `responseType`, `withCredentials`. -- `withCredentials` запроса имеет приоритет над `credentials` в `FetchAdapter`. +- `withCredentials` остаётся общей настройкой `HttpClient` и отдельного запроса, поскольку поддерживается Fetch и XHR. +- `IFetchAdapterOptions` и `IXhrAdapterOptions` являются публичными контрактами настроек адаптеров. +- Конфигурация запроса и `HttpClient` имеет приоритет над настройками XHR-адаптера для общих полей: `timeout`, `responseType`, `withCredentials`. +- `withCredentials` запроса имеет приоритет над `credentials` в `FetchAdapter`. ### Когда пересматривать @@ -41,9 +41,9 @@ Fetch- и XHR-специфичные параметры передаются в ### Последствия -- В комментариях и JSDoc исходников не используется русский текст. -- В пользовательской документации не используется английский связный текст; идентификаторы, имена API, значения и фрагменты кода сохраняют исходное написание. -- Новый `README.md` из задачи P0-1 должен быть написан на русском языке. +- В комментариях и JSDoc исходников не используется русский текст. +- В пользовательской документации не используется английский связный текст; идентификаторы, имена API, значения и фрагменты кода сохраняют исходное написание. +- Новый `README.md` из задачи P0-1 должен быть написан на русском языке. ### Когда пересматривать @@ -88,10 +88,10 @@ httpClient.get('/status').responseType('text').execute(); // Promise()`; для прочих поддерживаемых типов итоговый тип задаётся `responseType`. +- `HttpRequestBuilder` будет параметризован выбранным `responseType` только на уровне TypeScript. +- Fluent-методы должны сохранять параметр response type. +- Runtime-конфигурация запросов и адаптеры не изменятся. +- Для JSON сохраняется явный `execute()`; для прочих поддерживаемых типов итоговый тип задаётся `responseType`. ### Когда пересматривать @@ -137,11 +137,11 @@ Fetch- и XHR-адаптеры могут разобрать тело ошибк ### Последствия -- Адаптеры продолжат создавать `HttpResponseError`; их runtime-поведение не изменится. -- Generic-параметр guard будет доверенным TypeScript-сужением и не станет проверять структуру `data` во время выполнения. -- `data` после сужения сохранит возможность отсутствия: `T | undefined`. -- Проверка `instanceof` останется единственным runtime-критерием принадлежности к `HttpResponseError`. -- Понадобятся type-тесты для вызовов guard с generic-параметром и без него, а также runtime-тест ответа `400` или `422` с JSON-телом. +- Адаптеры продолжат создавать `HttpResponseError`; их runtime-поведение не изменится. +- Generic-параметр guard будет доверенным TypeScript-сужением и не станет проверять структуру `data` во время выполнения. +- `data` после сужения сохранит возможность отсутствия: `T | undefined`. +- Проверка `instanceof` останется единственным runtime-критерием принадлежности к `HttpResponseError`. +- Понадобятся type-тесты для вызовов guard с generic-параметром и без него, а также runtime-тест ответа `400` или `422` с JSON-телом. ### Когда пересматривать @@ -164,12 +164,39 @@ Fetch- и XHR-адаптеры могут разобрать тело ошибк ### Последствия -- `HttpClient` продолжит выполнять одну попытку запроса. -- `FetchAdapter` и `XhrAdapter` не получат retry-логику. -- Класс оркестрации будет использовать публичный API клиента и обрабатывать результаты отдельных попыток. -- Политика повторов останется opt-in. -- Правила повторения HTTP-методов, обработка `AbortSignal`, backoff и `Retry-After` должны быть спроектированы отдельно в рамках P2-4. +- `HttpClient` продолжит выполнять одну попытку запроса. +- `FetchAdapter` и `XhrAdapter` не получат retry-логику. +- Класс оркестрации будет использовать публичный API клиента и обрабатывать результаты отдельных попыток. +- Политика повторов останется opt-in. +- Правила повторения HTTP-методов, обработка `AbortSignal`, backoff и `Retry-After` должны быть спроектированы отдельно в рамках P2-4. ### Когда пересматривать -При появлении общего middleware-конвейера, который сможет предоставить эквивалентную изоляцию retry-политики без усложнения `HttpClient` и адаптеров. \ No newline at end of file +При появлении общего 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` должен означать отсутствие тела. diff --git a/services/http-client/TODO.md b/services/http-client/TODO.md index ebae809b..b22a714a 100644 --- a/services/http-client/TODO.md +++ b/services/http-client/TODO.md @@ -6,7 +6,7 @@ ### P0-1 — Релизная гигиена: README + changeset + версия -`homepage` в package.json указывает на несуществующий `#readme`, `.changeset/` пуст, версия `0.0.1` → `0.1.0` при первом publish. Нужны README с примерами (настройка Fetch/XHR, приоритеты конфигурации client → builder → adapter, `withCredentials`, ограничения CORS, таблица ошибок, hooks) и changeset перед паблишем. В README отдельно зафиксировать: маппинг `withCredentials` → fetch `credentials` (`true` → `include`, `false` → `same-origin`, `undefined` → `credentials` адаптера ?? `same-origin`) согласно D-001; контракт `body()` (допустимые типы, автосериализация объектов в JSON с авто-`Content-Type`); ограничение `redirect: 'manual'` (opaque-redirect приходит как `HttpResponseError` со статусом `0`, продолжить редирект вручную нельзя). +`homepage` в package.json указывает на несуществующий `#readme`, `.changeset/` пуст, версия `0.0.1` → `0.1.0` при первом publish. Нужны README с примерами (настройка Fetch/XHR, приоритеты конфигурации client → builder → adapter, `withCredentials`, ограничения CORS, таблица ошибок, hooks) и changeset перед паблишем. В README отдельно зафиксировать: маппинг `withCredentials` → fetch `credentials` (`true` → `include`, `false` → `same-origin`, `undefined` → `credentials` адаптера ?? `same-origin`) согласно D-001; контракт `body()` (допустимые типы, автосериализация объектов в JSON с авто-`Content-Type`, `body(undefined)` выбрасывает `RequestBuilderError`, `body(null)` отправляет JSON `null`) согласно D-006; ограничение `redirect: 'manual'` (opaque-redirect приходит как `HttpResponseError` со статусом `0`, продолжить редирект вручную нельзя). ## P1 @@ -36,15 +36,11 @@ Согласно D-005, повторы будут реализованы в отдельном opt-in классе для оркестрации запросов, без встраивания retry-логики в `HttpClient`, адаптеры или hooks. Нужно спроектировать лимит попыток, exponential backoff, поддержку `Retry-After`, отмену через `AbortSignal`, перечень временных сетевых сбоев и статусов (`408`, `429`, часть `5xx`), а также безопасное поведение для мутаций. -### P2-5 — Юнит-тесты утилит - -Изолированно покрыть `buildUrl`, `mergeHeaders`, `mergeParams`, `prepareRequestBody` (включая FormData без Content-Type, URLSearchParams, circular JSON). Заодно зафиксировать контракт `prepareRequestBody`, который мутирует переданный `headers` (JSDoc или возврат пары значений), и поведение `body(undefined)`: ключ `data` в config создаётся, но адаптеры тело не отправляют. - -### P2-6 — Зафиксировать edge-cases XHR stream +### P2-5 — Зафиксировать edge-cases XHR stream Поведение abort/timeout после resolve stream и 4xx при `responseType: 'stream'` должно быть явным контрактом (reject promise vs `stream.error`) с тестами. Смежное: расходящаяся семантика timeout для STREAM — fetch-таймер живёт до получения заголовков, `xhr.timeout` покрывает всю загрузку; задокументировать или унифицировать. Также задокументировать, что XHR-stream не настоящий стрим: ответ целиком накапливается в `responseText`, что ограничивает размер стриминговых ответов по памяти. -### P2-7 — Добавить браузерные интеграционные тесты +### P2-6 — Добавить браузерные интеграционные тесты Проверить в настоящих браузерах credentialed CORS, preflight, cookies с `SameSite`/`Secure`, redirects и `keepalive`. Тесты jsdom и mock-адаптеров не воспроизводят эти особенности платформы. Актуально после появления реальных пользователей credentials-API. @@ -89,3 +85,5 @@ 15. Валидация `FetchAdapter`: исключён `mode: 'navigate'`; `cache: 'only-if-cached'` требует `mode: 'same-origin'`. 16. Parity Fetch/XHR: `Content-Length: 0`, error body при `blob`/`formData` и fail-fast для недоступного `Response.formData`. 17. Пустые `params` нормализуются в `undefined` вместо `{}`. +18. Утилиты покрыты изолированными тестами и английскими JSDoc; зафиксирована мутация headers в `prepareRequestBody`. +19. Согласно D-006, `body(undefined)` выбрасывает `RequestBuilderError` с кодом `INVALID_BODY`, а `body(null)` отправляет JSON `null`. diff --git a/services/http-client/src/asserts/assertBodyAllowed.ts b/services/http-client/src/asserts/assertValidBody.ts similarity index 55% rename from services/http-client/src/asserts/assertBodyAllowed.ts rename to services/http-client/src/asserts/assertValidBody.ts index 144f4567..86180efe 100644 --- a/services/http-client/src/asserts/assertBodyAllowed.ts +++ b/services/http-client/src/asserts/assertValidBody.ts @@ -2,11 +2,16 @@ import { HTTP_METHODS, REQUEST_BUILDER_ERROR_CODES } from '../constants'; import { RequestBuilderError } from '../errors'; import { THttpMethod } from '../types'; -export function assertBodyAllowed(method: THttpMethod): void { +/** 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/assertValidRequestConfig.ts b/services/http-client/src/asserts/assertValidRequestConfig.ts index e3b8cebf..0f931a0d 100644 --- a/services/http-client/src/asserts/assertValidRequestConfig.ts +++ b/services/http-client/src/asserts/assertValidRequestConfig.ts @@ -1,7 +1,7 @@ import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; import { RequestBuilderError } from '../errors'; import { IHttpRequestConfig } from '../types'; -import { assertBodyAllowed } from './assertBodyAllowed'; +import { assertValidBody } from './assertValidBody'; import { assertValidBaseUrl } from './assertValidBaseUrl'; import { assertValidHeaders } from './assertValidHeaders'; import { assertValidMethod } from './assertValidMethod'; @@ -19,7 +19,7 @@ export function assertValidRequestConfig(config: unknown): asserts config is IHt throw new RequestBuilderError('Request config must be an object', REQUEST_BUILDER_ERROR_CODES.INVALID_CONFIG); } - const { method, url, baseUrl, headers, params, signal, timeout, withCredentials, responseType } = config; + const { method, url, baseUrl, headers, params, data, signal, timeout, withCredentials, responseType } = config; assertValidMethod(method); assertValidUrl(url); @@ -53,6 +53,6 @@ export function assertValidRequestConfig(config: unknown): asserts config is IHt } if (Object.prototype.hasOwnProperty.call(config, 'data')) { - assertBodyAllowed(method); + assertValidBody(method, data); } } diff --git a/services/http-client/src/asserts/index.ts b/services/http-client/src/asserts/index.ts index 19d25a34..6bad84a9 100644 --- a/services/http-client/src/asserts/index.ts +++ b/services/http-client/src/asserts/index.ts @@ -1,4 +1,4 @@ -export * from './assertBodyAllowed'; +export * from './assertValidBody'; export * from './assertValidFetchAdapterOptions'; export * from './assertNonBlankString'; export * from './assertValidBaseUrl'; diff --git a/services/http-client/src/core/HttpRequestBuilder.ts b/services/http-client/src/core/HttpRequestBuilder.ts index 73c74fa1..72543612 100644 --- a/services/http-client/src/core/HttpRequestBuilder.ts +++ b/services/http-client/src/core/HttpRequestBuilder.ts @@ -12,7 +12,7 @@ import { } from '../types'; import { HEADER_VALUE_LINE_BREAK_PATTERN, - assertBodyAllowed, + assertValidBody, assertNonBlankString, assertValidBaseUrl, assertValidHeader, @@ -106,7 +106,7 @@ export class HttpRequestBuilder { } public body(data: unknown): HttpRequestBuilder { - assertBodyAllowed(this.config.method); + assertValidBody(this.config.method, data); return this.withConfig({ data }); } diff --git a/services/http-client/src/utilities/buildUrl.ts b/services/http-client/src/utilities/buildUrl.ts index 2f22535a..3952e67f 100644 --- a/services/http-client/src/utilities/buildUrl.ts +++ b/services/http-client/src/utilities/buildUrl.ts @@ -103,7 +103,10 @@ function resolveAbsoluteUrl(base: URL, url: string): string { return base.toString(); } -/** Resolves a request URL and appends params before its fragment. */ +/** + * 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); diff --git a/services/http-client/src/utilities/getErrorMessage.ts b/services/http-client/src/utilities/getErrorMessage.ts index e860154c..1885479a 100644 --- a/services/http-client/src/utilities/getErrorMessage.ts +++ b/services/http-client/src/utilities/getErrorMessage.ts @@ -1,3 +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/mergeHeaders.ts b/services/http-client/src/utilities/mergeHeaders.ts index 7f6026de..4fb4e014 100644 --- a/services/http-client/src/utilities/mergeHeaders.ts +++ b/services/http-client/src/utilities/mergeHeaders.ts @@ -1,5 +1,6 @@ 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(); @@ -25,6 +26,7 @@ export function mergeHeaders(...sources: Array): THttp 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(); diff --git a/services/http-client/src/utilities/prepareRequestBody.ts b/services/http-client/src/utilities/prepareRequestBody.ts index 86dd36f7..e2795df3 100644 --- a/services/http-client/src/utilities/prepareRequestBody.ts +++ b/services/http-client/src/utilities/prepareRequestBody.ts @@ -20,6 +20,13 @@ 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')) { diff --git a/services/http-client/test/core/HttpClient.POST.tests.ts b/services/http-client/test/core/HttpClient.POST.tests.ts index 892a9ab3..65f8487b 100644 --- a/services/http-client/test/core/HttpClient.POST.tests.ts +++ b/services/http-client/test/core/HttpClient.POST.tests.ts @@ -51,6 +51,16 @@ describe.each(adapters)('HttpClient.$name — POST', ({ create }) => { 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) + .execute<{ received: string; contentType: string | null }>(); + + expect(response.data).toEqual({ received: 'null', contentType: 'application/json' }); + }); + test('sends FormData without JSON serialization', async () => { const client = createClient(); const formData = new FormData(); diff --git a/services/http-client/test/core/HttpClient.hooks.tests.ts b/services/http-client/test/core/HttpClient.hooks.tests.ts index bbacea5b..9ff2b11e 100644 --- a/services/http-client/test/core/HttpClient.hooks.tests.ts +++ b/services/http-client/test/core/HttpClient.hooks.tests.ts @@ -90,6 +90,21 @@ describe.each(adapters)('HttpClient.$name — hooks', ({ create }) => { }); }); + 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 config returned by onRequestError without sending the request', async () => { let hits = 0; server.use( diff --git a/services/http-client/test/core/HttpRequestBuilder.tests.ts b/services/http-client/test/core/HttpRequestBuilder.tests.ts index 31ea0332..b6e82b2f 100644 --- a/services/http-client/test/core/HttpRequestBuilder.tests.ts +++ b/services/http-client/test/core/HttpRequestBuilder.tests.ts @@ -237,6 +237,18 @@ describe('HttpRequestBuilder', () => { 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'] }) 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/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'); + }); +}); From 778aee9744b758e281aa9c2c35b10013b7c21121 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Sun, 23 Aug 2026 20:45:32 +0500 Subject: [PATCH 52/60] feat(http): type body --- services/http-client/DECISIONS.md | 45 ++++- services/http-client/README.md | 169 ++++++++++++++++++ services/http-client/TODO.md | 16 +- .../http-client/src/adapters/FetchAdapter.ts | 10 -- .../http-client/src/adapters/XhrAdapter.ts | 62 +------ .../src/constants/httpResponseTypes.ts | 1 - services/http-client/src/core/HttpClient.ts | 4 +- .../src/core/HttpRequestBuilder.ts | 54 +++--- .../__handlers__/HttpClient.GET.handlers.ts | 6 - .../test/adapters/AdapterOptions.tests.ts | 35 ++-- .../test/core/HttpClient.DELETE.tests.ts | 2 +- .../test/core/HttpClient.GET.tests.ts | 72 ++++---- .../test/core/HttpClient.HEAD.tests.ts | 4 +- .../test/core/HttpClient.OPTIONS.tests.ts | 2 +- .../test/core/HttpClient.PATCH.tests.ts | 5 +- .../test/core/HttpClient.POST.tests.ts | 27 ++- .../test/core/HttpClient.PUT.tests.ts | 5 +- .../test/core/HttpClient.constructor.tests.ts | 5 +- .../core/HttpClient.defaultConfig.tests.ts | 21 ++- .../test/core/HttpClient.hooks.tests.ts | 23 +-- .../test/core/HttpRequestBuilder.tests-d.ts | 29 ++- .../test/core/HttpRequestBuilder.tests.ts | 19 +- 22 files changed, 398 insertions(+), 218 deletions(-) create mode 100644 services/http-client/README.md diff --git a/services/http-client/DECISIONS.md b/services/http-client/DECISIONS.md index f9e8c402..88536630 100644 --- a/services/http-client/DECISIONS.md +++ b/services/http-client/DECISIONS.md @@ -121,7 +121,7 @@ interface IValidationError { } try { - await httpClient.post('/users').body(data).execute(); + 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 @@ -200,3 +200,46 @@ Fetch- и XHR-адаптеры могут разобрать тело ошибк ### Когда пересматривать При появлении отдельного явного 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` не связан с типом успешного ответа и проектируется отдельно в P1-1. + +### Когда пересматривать + +При появлении пользовательских декодеров, востребованного формата ответа без отдельного метода или необходимости получать непрочитанное тело независимо от транспорта. diff --git a/services/http-client/README.md b/services/http-client/README.md new file mode 100644 index 00000000..0be6be88 --- /dev/null +++ b/services/http-client/README.md @@ -0,0 +1,169 @@ +# @byndyusoft-ui/http-client + +HTTP-клиент с неизменяемым builder, адаптерами Fetch и XMLHttpRequest, типизированными ответами, hooks, отменой и таймаутами. + +## Установка + +```bash +npm install @byndyusoft-ui/http-client +``` + +## Быстрый старт + +По умолчанию используется `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`. Generic `asJson()` описывает ожидаемую схему и не проверяет данные во время выполнения. + +## Построение запроса + +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` тело запрещено. + +## Тело запроса + +`body()` принимает JSON-совместимые значения, строку, `FormData`, `URLSearchParams`, `Blob`, `ArrayBuffer` и `ArrayBufferView`. + +- Обычный объект автоматически сериализуется в JSON; при отсутствии заголовка добавляется `Content-Type: application/json`. +- `body(null)` отправляет JSON `null`. +- `body(undefined)` синхронно выбрасывает `RequestBuilderError`. +- При передаче `FormData` заголовок `Content-Type` вручную задавать не следует: браузер добавит boundary. + +## Адаптеры + +### 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-специфичные параметры задаются в конструкторе адаптера. При `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 + }) +}); +``` + +XHR полезен для сценариев, которым нужны возможности `XMLHttpRequest`. Текущий `asStream()` для XHR не является настоящим сетевым стримом: полученный текст накапливается в памяти. + +## Приоритет конфигурации + +Общие настройки разрешаются в следующем порядке: + +1. Настройки конкретного запроса в builder. +2. Настройки `HttpClient`. +3. Значения по умолчанию адаптера. + +Специфичные для 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`. + +Клиент не может самостоятельно разрешить CORS. Сервер должен возвращать подходящие `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers` и, для credentialed-запросов, `Access-Control-Allow-Credentials: true`. Cookie также подчиняются правилам `SameSite` и `Secure` браузера. + +## Ошибки + +| Ошибка | Причина | +| ------------------------- | ------------------------------------------ | +| `RequestBuilderError` | Некорректные настройки builder | +| `RequestPreparationError` | Не удалось подготовить транспортный запрос | +| `HttpResponseError` | HTTP-статус вне диапазона 2xx | +| `ParseError` | Не удалось декодировать успешный ответ | +| `NetworkError` | Сетевая ошибка | +| `AbortError` | Запрос отменён через `AbortSignal` | +| `TimeoutError` | Истёк таймаут | + +```ts +import { isHttpResponseError } from '@byndyusoft-ui/http-client'; + +try { + await httpClient.get('/users/1').asJson().execute(); +} catch (error) { + if (isHttpResponseError(error)) { + console.error(error.status, error.data); + } +} +``` + +## Hooks + +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 заменяет предыдущее значение. diff --git a/services/http-client/TODO.md b/services/http-client/TODO.md index b22a714a..169dd486 100644 --- a/services/http-client/TODO.md +++ b/services/http-client/TODO.md @@ -4,9 +4,9 @@ ## P0 -### P0-1 — Релизная гигиена: README + changeset + версия +### P0-1 — Релизная гигиена: changeset + версия -`homepage` в package.json указывает на несуществующий `#readme`, `.changeset/` пуст, версия `0.0.1` → `0.1.0` при первом publish. Нужны README с примерами (настройка Fetch/XHR, приоритеты конфигурации client → builder → adapter, `withCredentials`, ограничения CORS, таблица ошибок, hooks) и changeset перед паблишем. В README отдельно зафиксировать: маппинг `withCredentials` → fetch `credentials` (`true` → `include`, `false` → `same-origin`, `undefined` → `credentials` адаптера ?? `same-origin`) согласно D-001; контракт `body()` (допустимые типы, автосериализация объектов в JSON с авто-`Content-Type`, `body(undefined)` выбрасывает `RequestBuilderError`, `body(null)` отправляет JSON `null`) согласно D-006; ограничение `redirect: 'manual'` (opaque-redirect приходит как `HttpResponseError` со статусом `0`, продолжить редирект вручную нельзя). +README создан и описывает публичный API, адаптеры, приоритеты конфигурации, CORS, body, ошибки и hooks. Перед первым publish остаются changeset и повышение версии `0.0.1` → `0.1.0`. ## P1 @@ -14,10 +14,6 @@ `HttpResponseError` уже параметризован, но адаптеры создают его с `unknown`, а guard не позволяет вызывающему коду указать ожидаемый тип. Вариант с доверенным generic-guard, например `isHttpResponseError(error)`, зафиксирован в D-004 со статусом «не принято». После выбора контракта нужны явные сценарии для `400` и `422` и type-тесты. -### P1-2 — Вывод типа данных ответа из `responseType` - -`responseType('blob').execute()` возвращает `unknown`, а рассинхрон `responseType('blob')` + `execute()` не ловится компилятором. Вариант, при котором JSON сохраняет явный `execute()`, а `text`, `blob`, `arrayBuffer`, `formData` и `stream` получают тип из `responseType`, зафиксирован в D-003 со статусом «не принято». После выбора контракта понадобятся type-тесты. Сделать до README, чтобы документировать финальный API. - ## P2 ### P2-1 — Модернизация package.json @@ -60,7 +56,7 @@ ## Рекомендуемый порядок -1. P0: README + changeset (после P1 «вывод типа данных ответа», чтобы документировать финальный API) +1. P0: changeset + версия 2. P1: typed errors 3. P2: package.json модернизация вместе с релизной подготовкой 4. P2: retry / progress / validateStatus по продуктовым нуждам @@ -73,7 +69,7 @@ 3. Body: FormData / URLSearchParams / Blob / ArrayBufferView (`prepareRequestBody`) 4. Default `params`, number/boolean params, null-skip 5. Options/config validation, `RequestPreparationError` -6. formData / stream response types +6. `stream` response type 7. `FetchAdapter`: `ParseError`, если streaming body отсутствует (`response.body === null`) 8. `HttpResponseError` передаёт `config` в базовый `HttpClientError` 9. Default `FetchAdapter`, `withCredentials` (client / builder / оба адаптера) @@ -83,7 +79,9 @@ 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`/`formData` и fail-fast для недоступного `Response.formData`. +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()` сохранена. diff --git a/services/http-client/src/adapters/FetchAdapter.ts b/services/http-client/src/adapters/FetchAdapter.ts index 496ffd77..ae0cc422 100644 --- a/services/http-client/src/adapters/FetchAdapter.ts +++ b/services/http-client/src/adapters/FetchAdapter.ts @@ -126,16 +126,6 @@ async function parseResponseBody( return response.blob() as Promise; case HTTP_RESPONSE_TYPES.ARRAY_BUFFER: return response.arrayBuffer() as Promise; - case HTTP_RESPONSE_TYPES.FORM_DATA: - try { - return (await response.formData()) as T; - } catch (error) { - throw new ParseError('Failed to parse response body as FormData', { - cause: error, - config, - responseType: HTTP_RESPONSE_TYPES.FORM_DATA - }); - } case HTTP_RESPONSE_TYPES.STREAM: if (response.body === null) { throw new ParseError('Streaming response body is not available', { diff --git a/services/http-client/src/adapters/XhrAdapter.ts b/services/http-client/src/adapters/XhrAdapter.ts index 91cf84df..391db66b 100644 --- a/services/http-client/src/adapters/XhrAdapter.ts +++ b/services/http-client/src/adapters/XhrAdapter.ts @@ -69,29 +69,6 @@ function createResponseStream(xhr: XMLHttpRequest): IXhrResponseStream { }; } -async function readBlob(blob: Blob): Promise { - if (typeof blob.arrayBuffer === 'function') { - return blob.arrayBuffer(); - } - - return new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onload = () => { - if (typeof reader.result === 'string' || reader.result === null) { - reject(new TypeError('Failed to read response Blob as an ArrayBuffer')); - return; - } - - resolve(reader.result); - }; - reader.onerror = () => { - reject(reader.error ?? new TypeError('Failed to read response Blob as an ArrayBuffer')); - }; - reader.readAsArrayBuffer(blob); - }); -} - async function readBlobAsText(blob: Blob): Promise { if (typeof blob.text === 'function') { return blob.text(); @@ -119,11 +96,7 @@ function hasEmptyResponseBody(xhr: XMLHttpRequest): boolean { return xhr.status === 204 || xhr.getResponseHeader('content-length') === '0'; } -async function getResponseBody( - xhr: XMLHttpRequest, - config: IHttpRequestConfig, - responseType?: THttpResponseType -): Promise { +function getResponseBody(xhr: XMLHttpRequest, config: IHttpRequestConfig, responseType?: THttpResponseType): T { if (hasEmptyResponseBody(xhr)) { return undefined as T; } @@ -134,21 +107,6 @@ async function getResponseBody( return xhr.response as T; case HTTP_RESPONSE_TYPES.TEXT: return xhr.response as T; - case HTTP_RESPONSE_TYPES.FORM_DATA: - try { - const body = xhr.response as Blob; - const response = new Response(await readBlob(body), { - headers: parseResponseHeaders(xhr.getAllResponseHeaders()) - }); - - return (await response.formData()) as T; - } catch (error) { - throw new ParseError('Failed to parse response body as FormData', { - cause: error, - config, - responseType: HTTP_RESPONSE_TYPES.FORM_DATA - }); - } case HTTP_RESPONSE_TYPES.JSON: default: { const resolvedResponseType = responseType ?? HTTP_RESPONSE_TYPES.JSON; @@ -180,7 +138,7 @@ function setResponseType(xhr: XMLHttpRequest, responseType: THttpResponseType | if (responseType === HTTP_RESPONSE_TYPES.ARRAY_BUFFER) { // XHR accepts only a lowercase DOM value. xhr.responseType = 'arraybuffer'; - } else if (responseType === HTTP_RESPONSE_TYPES.BLOB || responseType === HTTP_RESPONSE_TYPES.FORM_DATA) { + } else if (responseType === HTTP_RESPONSE_TYPES.BLOB) { xhr.responseType = 'blob'; } else { xhr.responseType = 'text'; @@ -196,18 +154,6 @@ function assertStreamingSupported(responseType: THttpResponseType | undefined, c } } -function assertFormDataResponseSupported( - responseType: THttpResponseType | undefined, - config: IHttpRequestConfig -): void { - if ( - responseType === HTTP_RESPONSE_TYPES.FORM_DATA && - (typeof Response === 'undefined' || typeof Response.prototype.formData !== 'function') - ) { - throw new RequestPreparationError('FormData responses are not supported in this environment', { config }); - } -} - function createResponse(xhr: XMLHttpRequest, config: IHttpRequestConfig, data: T): IHttpResponse { return { data, @@ -302,8 +248,6 @@ function prepareXhrRequest(config: IHttpRequestConfig, options: IXhrAdapterOptio } assertStreamingSupported(responseType, config); - assertFormDataResponseSupported(responseType, config); - const xhr = new XMLHttpRequest(); xhr.open(method, fullUrl, true); xhr.withCredentials = config.withCredentials === true; @@ -399,7 +343,7 @@ function configureXhrEventHandlers( } try { - const responseData = await getResponseBody(xhr, config, responseType); + const responseData = getResponseBody(xhr, config, responseType); resolve(createResponse(xhr, config, responseData)); } catch (error) { reject( diff --git a/services/http-client/src/constants/httpResponseTypes.ts b/services/http-client/src/constants/httpResponseTypes.ts index ba24e0d7..8e67908b 100644 --- a/services/http-client/src/constants/httpResponseTypes.ts +++ b/services/http-client/src/constants/httpResponseTypes.ts @@ -1,7 +1,6 @@ export const HTTP_RESPONSE_TYPES = { ARRAY_BUFFER: 'arrayBuffer', BLOB: 'blob', - FORM_DATA: 'formData', JSON: 'json', STREAM: 'stream', TEXT: 'text' diff --git a/services/http-client/src/core/HttpClient.ts b/services/http-client/src/core/HttpClient.ts index 87428188..386c7d75 100644 --- a/services/http-client/src/core/HttpClient.ts +++ b/services/http-client/src/core/HttpClient.ts @@ -75,7 +75,7 @@ export class HttpClient { private request(method: THttpMethod, url: string): HttpRequestBuilder { const executor: THttpRequestExecutor = (config: IHttpRequestConfig): Promise> => - this.execute(config); + this.executeRequest(config); return new HttpRequestBuilder(executor, method, url); } @@ -108,7 +108,7 @@ export class HttpClient { return this.request(HTTP_METHODS.PATCH, url); } - private async execute(config: IHttpRequestConfig): Promise> { + private async executeRequest(config: IHttpRequestConfig): Promise> { const { onRequestHook: onRequest, onRequestErrorHook: onRequestError, diff --git a/services/http-client/src/core/HttpRequestBuilder.ts b/services/http-client/src/core/HttpRequestBuilder.ts index 72543612..1ffef475 100644 --- a/services/http-client/src/core/HttpRequestBuilder.ts +++ b/services/http-client/src/core/HttpRequestBuilder.ts @@ -1,4 +1,4 @@ -import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; +import { HTTP_RESPONSE_TYPES, REQUEST_BUILDER_ERROR_CODES } from '../constants'; import { RequestBuilderError } from '../errors'; import { THttpMethod, @@ -6,7 +6,6 @@ import { IHttpResponse, THttpHeaders, THttpParams, - THttpResponseType, THttpParamValue, THttpRequestExecutor } from '../types'; @@ -21,7 +20,6 @@ import { assertValidParam, assertValidParams, assertValidRequestConfig, - assertValidResponseType, assertValidSignal, assertValidTimeout, assertValidUrl, @@ -42,7 +40,7 @@ function cloneConfig(config: IHttpRequestConfig): IHttpRequestConfig { /** * Builds request configs immutably. Every configuration method returns a new builder instance. */ -export class HttpRequestBuilder { +export class HttpRequestBuilder { private readonly executor: THttpRequestExecutor; private config: IHttpRequestConfig; @@ -58,8 +56,8 @@ export class HttpRequestBuilder { this.config = { method, url }; } - private withConfig(partial: TBuilderConfigPatch): HttpRequestBuilder { - const builder = new HttpRequestBuilder(this.executor, this.config.method, this.config.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; @@ -71,25 +69,25 @@ export class HttpRequestBuilder { return cloneConfig(this.config); } - public baseUrl(value: string): HttpRequestBuilder { + public baseUrl(value: string): HttpRequestBuilder { assertValidBaseUrl(value); return this.withConfig({ baseUrl: value }); } - public header(key: string, value: string): HttpRequestBuilder { + 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 { + public headers(headers: THttpHeaders): HttpRequestBuilder { assertValidHeaders(headers); return this.withConfig({ headers: mergeHeaders(this.config.headers, headers) }); } - public param(key: string, value: THttpParamValue): HttpRequestBuilder { + public param(key: string, value: THttpParamValue): HttpRequestBuilder { assertValidParam(key, value); return this.withConfig({ @@ -97,7 +95,7 @@ export class HttpRequestBuilder { }); } - public params(params: THttpParams): HttpRequestBuilder { + public params(params: THttpParams): HttpRequestBuilder { assertValidParams(params); return this.withConfig({ @@ -105,33 +103,33 @@ export class HttpRequestBuilder { }); } - public body(data: unknown): HttpRequestBuilder { + public body(data: unknown): HttpRequestBuilder { assertValidBody(this.config.method, data); return this.withConfig({ data }); } - public signal(signal: AbortSignal): HttpRequestBuilder { + 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 { + public timeout(timeout: number): HttpRequestBuilder { assertValidTimeout(timeout); return this.withConfig({ timeout }); } /** Includes credentials in cross-origin requests. */ - public withCredentials(withCredentials: boolean): HttpRequestBuilder { + public withCredentials(withCredentials: boolean): HttpRequestBuilder { assertValidWithCredentials(withCredentials); return this.withConfig({ withCredentials }); } - public bearer(token: string): HttpRequestBuilder { + public bearer(token: string): HttpRequestBuilder { assertNonBlankString( token, 'Bearer token must be a non-empty string', @@ -148,13 +146,27 @@ export class HttpRequestBuilder { return this.header('Authorization', `Bearer ${token}`); } - public responseType(responseType: THttpResponseType): HttpRequestBuilder { - assertValidResponseType(responseType); + 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 }); + } - return this.withConfig({ responseType }); + public asStream(): HttpRequestBuilder> { + return this.withConfig>({ responseType: HTTP_RESPONSE_TYPES.STREAM }); } - public execute(): Promise> { - return this.executor(this.build()); + public execute(): Promise> { + return this.executor(this.build()); } } diff --git a/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts index 977eccc7..80ea5cde 100644 --- a/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts +++ b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts @@ -42,12 +42,6 @@ export const handlers = [ return new HttpResponse(bytes.buffer, { headers: { 'Content-Type': 'application/octet-stream' } }); }), - http.get(`${BASE_URL}/form-data`, () => { - return new HttpResponse('name=John&role=admin&role=editor', { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' } - }); - }), - http.get(`${BASE_URL}/stream`, () => { return new HttpResponse('stream response', { headers: { 'Content-Type': 'text/plain' } }); }), diff --git a/services/http-client/test/adapters/AdapterOptions.tests.ts b/services/http-client/test/adapters/AdapterOptions.tests.ts index 5d44333b..34e8f426 100644 --- a/services/http-client/test/adapters/AdapterOptions.tests.ts +++ b/services/http-client/test/adapters/AdapterOptions.tests.ts @@ -2,8 +2,8 @@ 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, RequestPreparationError } from '../../src/errors'; -import { IFetchAdapterOptions, TRequestBuilderErrorCode } from '../../src/types'; +import { RequestBuilderError } from '../../src/errors'; +import { IFetchAdapterOptions, IXhrAdapterOptions, TRequestBuilderErrorCode } from '../../src/types'; function createMockXhr(response: unknown = 'response'): { xhr: XMLHttpRequest; @@ -122,7 +122,7 @@ describe('adapter constructor options', () => { withCredentials: true }); - const response = await new HttpClient({ adapter }).get('https://api.example.test/items').execute(); + 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); @@ -154,10 +154,10 @@ describe('adapter constructor options', () => { const response = await new HttpClient({ adapter }) .get('https://api.example.test/items') - .responseType(HTTP_RESPONSE_TYPES.ARRAY_BUFFER) + .asArrayBuffer() .timeout(50) .withCredentials(false) - .execute(); + .execute(); expect(mockXhr.xhr.withCredentials).toBe(false); expect(mockXhr.xhr.timeout).toBe(50); @@ -172,26 +172,6 @@ describe('adapter constructor options', () => { } }); - test('rejects XHR FormData responses when Response.formData is unavailable', async () => { - const mockXhr = createMockXhr(); - const xhrFactory = vi.fn(() => mockXhr.xhr); - vi.stubGlobal('XMLHttpRequest', xhrFactory); - vi.stubGlobal('Response', undefined); - - try { - await expect( - new HttpClient({ adapter: new XhrAdapter() }) - .get('https://api.example.test/items') - .responseType(HTTP_RESPONSE_TYPES.FORM_DATA) - .execute() - ).rejects.toBeInstanceOf(RequestPreparationError); - - expect(xhrFactory).not.toHaveBeenCalled(); - } finally { - vi.unstubAllGlobals(); - } - }); - test.each([ { name: 'Fetch options object', @@ -222,6 +202,11 @@ describe('adapter constructor 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 } ])('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 index 43e0a6cc..9375973a 100644 --- a/services/http-client/test/core/HttpClient.DELETE.tests.ts +++ b/services/http-client/test/core/HttpClient.DELETE.tests.ts @@ -30,7 +30,7 @@ describe.each(adapters)('HttpClient.$name — DELETE', ({ create }) => { test('returns 204 with undefined data', async () => { const client = createClient(); - const response = await client.delete('/items/1').responseType('text').execute(); + 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 index 9cb14f82..098987df 100644 --- a/services/http-client/test/core/HttpClient.GET.tests.ts +++ b/services/http-client/test/core/HttpClient.GET.tests.ts @@ -31,7 +31,7 @@ describe.each(adapters)('HttpClient.$name — GET', ({ name, create }) => { test('returns parsed JSON with status and statusText', async () => { const client = createClient(); - const response = await client.get('/users/1').execute<{ id: number; name: string }>(); + 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); @@ -43,7 +43,8 @@ describe.each(adapters)('HttpClient.$name — GET', ({ name, create }) => { const response = await client .get('/users') .params({ page: '2', role: ['admin', 'user'] }) - .execute<{ page: string | null; role: string[] }>(); + .asJson<{ page: string | null; role: string[] }>() + .execute(); expect(response.data?.page).toBe('2'); expect(response.data?.role).toEqual(['admin', 'user']); @@ -54,7 +55,8 @@ describe.each(adapters)('HttpClient.$name — GET', ({ name, create }) => { const response = await client .get('/users') .params({ page: 2, active: true, value: [0, false, 'all'] }) - .execute<{ page: string | null; active: string | null; value: string[] }>(); + .asJson<{ page: string | null; active: string | null; value: string[] }>() + .execute(); expect(response.data).toMatchObject({ page: '2', @@ -68,13 +70,14 @@ describe.each(adapters)('HttpClient.$name — GET', ({ name, create }) => { const response = await client .get('/users') .params({ page: 2, active: null, source: undefined, value: [1, null, undefined, 2] }) - .execute<{ + .asJson<{ page: string | null; active: string | null; source: string | null; value: string[]; keys: string[]; - }>(); + }>() + .execute(); expect(response.data).toMatchObject({ page: '2', @@ -90,7 +93,8 @@ describe.each(adapters)('HttpClient.$name — GET', ({ name, create }) => { const response = await client .get('/users?source=existing#fragment') .param('page', '2') - .execute<{ page: string | null; source: string | null }>(); + .asJson<{ page: string | null; source: string | null }>() + .execute(); expect(response.data?.source).toBe('existing'); expect(response.data?.page).toBe('2'); @@ -98,7 +102,7 @@ describe.each(adapters)('HttpClient.$name — GET', ({ name, create }) => { 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').execute<{ scoped: boolean }>(); + const response = await client.get('/users').asJson<{ scoped: boolean }>().execute(); expect(response.data?.scoped).toBe(true); }); @@ -109,7 +113,8 @@ describe.each(adapters)('HttpClient.$name — GET', ({ name, create }) => { .get('/headers') .header('Authorization', 'Bearer token') .header('X-Custom', 'value') - .execute<{ auth: string | null; custom: string | null }>(); + .asJson<{ auth: string | null; custom: string | null }>() + .execute(); expect(response.data?.auth).toBe('Bearer token'); expect(response.data?.custom).toBe('value'); @@ -121,14 +126,15 @@ describe.each(adapters)('HttpClient.$name — GET', ({ name, create }) => { .get('/headers') .header('Authorization', 'Bearer first') .header('authorization', 'Bearer second') - .execute<{ auth: string | null }>(); + .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').responseType('text').execute(); + const response = await client.get('/text').asText().execute(); expect(response.data).toBe('hello world'); }); @@ -142,29 +148,26 @@ describe.each(adapters)('HttpClient.$name — GET', ({ name, create }) => { test('returns ArrayBuffer when responseType is arrayBuffer', async () => { const client = createClient(); - const response = await client - .get('/binary') - .responseType(HTTP_RESPONSE_TYPES.ARRAY_BUFFER) - .execute(); + 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 FormData when responseType is formData', async () => { + test('returns Blob when responseType is blob', async () => { const client = createClient(); - const response = await client.get('/form-data').responseType(HTTP_RESPONSE_TYPES.FORM_DATA).execute(); + const response = await client.get('/binary').asBlob().execute(); + + if (response.data === undefined) { + throw new Error('Expected Blob response data'); + } - expect(response.data?.get('name')).toBe('John'); - expect(response.data?.getAll('role')).toEqual(['admin', 'editor']); + 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') - .responseType(HTTP_RESPONSE_TYPES.STREAM) - .execute>(); + const response = await client.get('/stream').asStream().execute(); if (response.data === undefined) { throw new Error('Expected stream response data'); @@ -195,7 +198,7 @@ describe.each(adapters)('HttpClient.$name — GET', ({ name, create }) => { ); try { - await client.get('/stream').responseType(HTTP_RESPONSE_TYPES.STREAM).execute(); + await client.get('/stream').asStream().execute(); expect.fail('Should have thrown'); } catch (error) { expect(error).toBeInstanceOf(ParseError); @@ -226,20 +229,17 @@ describe.each(adapters)('HttpClient.$name — GET', ({ name, create }) => { } }); - test.each([HTTP_RESPONSE_TYPES.BLOB, HTTP_RESPONSE_TYPES.FORM_DATA])( - 'parses error data with %s responseType', - async responseType => { - const client = createClient(); - - try { - await client.get('/not-found').responseType(responseType).execute(); - expect.fail('Should have thrown'); - } catch (error) { - expect(error).toBeInstanceOf(HttpResponseError); - expect((error as HttpResponseError).data).toEqual({ error: 'Not found' }); - } + 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(); diff --git a/services/http-client/test/core/HttpClient.HEAD.tests.ts b/services/http-client/test/core/HttpClient.HEAD.tests.ts index 06cc54f6..d3c9b823 100644 --- a/services/http-client/test/core/HttpClient.HEAD.tests.ts +++ b/services/http-client/test/core/HttpClient.HEAD.tests.ts @@ -30,9 +30,9 @@ describe.each(adapters)('HttpClient.$name — HEAD', ({ create }) => { test('returns headers without body', async () => { const client = createClient(); - const response = await client.head('/items').responseType('text').execute(); + const response = await client.head('/items').asText().execute(); expect(response.status).toBe(HTTP_STATUS_CODES.OK); expect(response.headers['x-total']).toBe('42'); }); -}); \ No newline at end of file +}); diff --git a/services/http-client/test/core/HttpClient.OPTIONS.tests.ts b/services/http-client/test/core/HttpClient.OPTIONS.tests.ts index caacc6dc..54161ef1 100644 --- a/services/http-client/test/core/HttpClient.OPTIONS.tests.ts +++ b/services/http-client/test/core/HttpClient.OPTIONS.tests.ts @@ -30,7 +30,7 @@ describe.each(adapters)('HttpClient.$name — OPTIONS', ({ create }) => { test('returns Allow header', async () => { const client = createClient(); - const response = await client.options('/items').responseType('text').execute(); + 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 index 8e58b30c..ee8a5877 100644 --- a/services/http-client/test/core/HttpClient.PATCH.tests.ts +++ b/services/http-client/test/core/HttpClient.PATCH.tests.ts @@ -32,8 +32,9 @@ describe.each(adapters)('HttpClient.$name — PATCH', ({ create }) => { const response = await client .patch('/items/1') .body({ name: 'Patched' }) - .execute<{ patched: boolean; name: string }>(); + .asJson<{ patched: boolean; name: string }>() + .execute(); expect(response.data).toEqual({ patched: true, name: 'Patched' }); }); -}); \ No newline at end of file +}); diff --git a/services/http-client/test/core/HttpClient.POST.tests.ts b/services/http-client/test/core/HttpClient.POST.tests.ts index 65f8487b..56a1a416 100644 --- a/services/http-client/test/core/HttpClient.POST.tests.ts +++ b/services/http-client/test/core/HttpClient.POST.tests.ts @@ -33,7 +33,8 @@ describe.each(adapters)('HttpClient.$name — POST', ({ create }) => { const response = await client .post('/items') .body({ name: 'New item' }) - .execute<{ received: { name: string }; contentType: string | null }>(); + .asJson<{ received: { name: string }; contentType: string | null }>() + .execute(); expect(response.data?.received).toEqual({ name: 'New item' }); expect(response.data?.contentType).toBe('application/json'); @@ -45,7 +46,8 @@ describe.each(adapters)('HttpClient.$name — POST', ({ create }) => { .post('/raw') .body('plain text body') .header('Content-Type', 'text/plain') - .execute<{ received: string; contentType: string | null }>(); + .asJson<{ received: string; contentType: string | null }>() + .execute(); expect(response.data?.received).toBe('plain text body'); expect(response.data?.contentType).toBe('text/plain'); @@ -56,7 +58,8 @@ describe.each(adapters)('HttpClient.$name — POST', ({ create }) => { const response = await client .post('/raw') .body(null) - .execute<{ received: string; contentType: string | null }>(); + .asJson<{ received: string; contentType: string | null }>() + .execute(); expect(response.data).toEqual({ received: 'null', contentType: 'application/json' }); }); @@ -71,7 +74,8 @@ describe.each(adapters)('HttpClient.$name — POST', ({ create }) => { const response = await client .post('/form-data') .body(formData) - .execute<{ name: string; roles: string[]; contentType: string | null }>(); + .asJson<{ name: string; roles: string[]; contentType: string | null }>() + .execute(); expect(response.data?.name).toBe('Jane'); expect(response.data?.roles).toEqual(['admin', 'editor']); @@ -88,7 +92,8 @@ describe.each(adapters)('HttpClient.$name — POST', ({ create }) => { const response = await client .post('/url-search-params') .body(params) - .execute<{ name: string; roles: string[]; contentType: string | null }>(); + .asJson<{ name: string; roles: string[]; contentType: string | null }>() + .execute(); expect(response.data).toEqual({ name: 'Jane', @@ -102,7 +107,8 @@ describe.each(adapters)('HttpClient.$name — POST', ({ create }) => { const response = await client .post('/binary') .body(new NodeBlob([new Uint8Array([0, 1, 255])])) - .execute<{ received: number[] }>(); + .asJson<{ received: number[] }>() + .execute(); expect(response.data?.received).toEqual([0, 1, 255]); }); @@ -112,7 +118,8 @@ describe.each(adapters)('HttpClient.$name — POST', ({ create }) => { const response = await client .post('/binary') .body(new Uint8Array([0, 1, 255])) - .execute<{ received: number[] }>(); + .asJson<{ received: number[] }>() + .execute(); expect(response.data?.received).toEqual([0, 1, 255]); }); @@ -126,7 +133,8 @@ describe.each(adapters)('HttpClient.$name — POST', ({ create }) => { const response = await client .post('/items') .body({ name: 'New item' }) - .execute<{ contentType: string | null }>(); + .asJson<{ contentType: string | null }>() + .execute(); expect(response.data?.contentType).toBe('application/vnd.api+json'); }); @@ -136,7 +144,8 @@ describe.each(adapters)('HttpClient.$name — POST', ({ create }) => { const response = await client .post('/echo') .body({ name: 'Jane' }) - .execute<{ created: boolean; name: string }>(); + .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 index f492735b..e2a8f392 100644 --- a/services/http-client/test/core/HttpClient.PUT.tests.ts +++ b/services/http-client/test/core/HttpClient.PUT.tests.ts @@ -32,8 +32,9 @@ describe.each(adapters)('HttpClient.$name — PUT', ({ create }) => { const response = await client .put('/items/1') .body({ name: 'Updated' }) - .execute<{ updated: boolean; name: string }>(); + .asJson<{ updated: boolean; name: string }>() + .execute(); expect(response.data).toEqual({ updated: true, name: 'Updated' }); }); -}); \ No newline at end of file +}); diff --git a/services/http-client/test/core/HttpClient.constructor.tests.ts b/services/http-client/test/core/HttpClient.constructor.tests.ts index aabc9f00..af5ee827 100644 --- a/services/http-client/test/core/HttpClient.constructor.tests.ts +++ b/services/http-client/test/core/HttpClient.constructor.tests.ts @@ -114,7 +114,10 @@ describe('HttpClient constructor', () => { vi.stubGlobal('fetch', fetchMock); try { - const response = await new HttpClient({}).get('https://example.test/health').execute<{ ok: boolean }>(); + const response = await new HttpClient({}) + .get('https://example.test/health') + .asJson<{ ok: boolean }>() + .execute(); expect(response.data).toEqual({ ok: true }); expect(fetchMock).toHaveBeenCalledWith( diff --git a/services/http-client/test/core/HttpClient.defaultConfig.tests.ts b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts index 3f47fd73..25ac0e93 100644 --- a/services/http-client/test/core/HttpClient.defaultConfig.tests.ts +++ b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts @@ -34,7 +34,8 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { const response = await client .get('/test') .header('X-Custom', 'value') - .execute<{ def: string | null; custom: string | null }>(); + .asJson<{ def: string | null; custom: string | null }>() + .execute(); expect(response.data?.def).toBe('default-header'); expect(response.data?.custom).toBe('value'); @@ -45,7 +46,8 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { const response = await client .get('/test') .header('X-Default', 'overridden') - .execute<{ def: string | null; custom: string | null }>(); + .asJson<{ def: string | null; custom: string | null }>() + .execute(); expect(response.data?.def).toBe('overridden'); }); @@ -55,7 +57,8 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { const response = await client .get('/test') .header('authorization', 'Bearer request') - .execute<{ authorization: string | null }>(); + .asJson<{ authorization: string | null }>() + .execute(); expect(response.data?.authorization).toBe('Bearer request'); }); @@ -69,7 +72,8 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { const response = await client .get('/test') - .execute<{ locale: string | null; page: string | null; role: string[] }>(); + .asJson<{ locale: string | null; page: string | null; role: string[] }>() + .execute(); expect(response.data).toMatchObject({ locale: 'ru', page: '1', role: ['admin', 'editor'] }); }); @@ -84,7 +88,8 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { const response = await client .get('/test') .params({ page: '2', role: ['editor'] }) - .execute<{ locale: string | null; page: string | null; role: string[] }>(); + .asJson<{ locale: string | null; page: string | null; role: string[] }>() + .execute(); expect(response.data).toMatchObject({ locale: 'ru', page: '2', role: ['editor'] }); }); @@ -117,7 +122,7 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { params.locale = 'en'; params.role.push('editor'); - const response = await client.get('/test').execute<{ locale: string | null; role: string[] }>(); + const response = await client.get('/test').asJson<{ locale: string | null; role: string[] }>().execute(); expect(response.data).toMatchObject({ locale: 'ru', role: ['admin'] }); }); @@ -129,7 +134,7 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { headers['X-Default'] = 'changed-after-creation'; headers['X-Added'] = 'must-not-be-sent'; - const response = await client.get('/test').execute<{ def: string | null; custom: string | null }>(); + 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(); @@ -137,7 +142,7 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { test('uses default baseUrl', async () => { const client = createClient(); - const response = await client.get('/base-test').execute<{ ok: boolean }>(); + const response = await client.get('/base-test').asJson<{ ok: boolean }>().execute(); expect(response.data?.ok).toBe(true); }); diff --git a/services/http-client/test/core/HttpClient.hooks.tests.ts b/services/http-client/test/core/HttpClient.hooks.tests.ts index 9ff2b11e..6ad7d41c 100644 --- a/services/http-client/test/core/HttpClient.hooks.tests.ts +++ b/services/http-client/test/core/HttpClient.hooks.tests.ts @@ -49,7 +49,8 @@ describe.each(adapters)('HttpClient.$name — hooks', ({ create }) => { const response = await client .get('/echo-headers') .header('X-Custom', 'value') - .execute<{ authorization: string | null }>(); + .asJson<{ authorization: string | null }>() + .execute(); expect(receivedConfig?.baseUrl).toBe(BASE_URL); expect(receivedConfig?.timeout).toBe(5000); @@ -66,7 +67,7 @@ describe.each(adapters)('HttpClient.$name — hooks', ({ create }) => { }) }); - const response = await client.get('/echo-headers').execute<{ authorization: string | null }>(); + const response = await client.get('/echo-headers').asJson<{ authorization: string | null }>().execute(); expect(response.data?.authorization).toBe('Bearer async-token'); }); @@ -82,7 +83,7 @@ describe.each(adapters)('HttpClient.$name — hooks', ({ create }) => { } }); - const response = await client.get('/items').execute<{ id: number }>(); + const response = await client.get('/items').asJson<{ id: number }>().execute(); expect(response.data?.id).toBe(1); expect(caught).toMatchObject({ @@ -166,7 +167,7 @@ describe.each(adapters)('HttpClient.$name — hooks', ({ create }) => { } }); - const response = await client.get('/items').execute<{ id: number }>(); + const response = await client.get('/items').asJson<{ id: number }>().execute(); expect(response.data?.id).toBe(1); }); @@ -191,7 +192,7 @@ describe.each(adapters)('HttpClient.$name — hooks', ({ create }) => { const onResponseError = vi.fn(); const client = createClient({ onResponse, onResponseError }); - const response = await client.get('/items').execute<{ wrapped: { id: number; name: string } }>(); + 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' }); @@ -294,7 +295,7 @@ describe.each(adapters)('HttpClient.$name — hooks', ({ create }) => { }) }); - const response = await client.get('/not-found').execute<{ recovered: boolean }>(); + const response = await client.get('/not-found').asJson<{ recovered: boolean }>().execute(); expect(response.data).toEqual({ recovered: true }); expect(onResponse).not.toHaveBeenCalled(); @@ -331,7 +332,7 @@ describe.each(adapters)('HttpClient.$name — hooks', ({ create }) => { headers: { ...config.headers, Authorization: 'Bearer instance-token' } })); - const response = await client.get('/echo-headers').execute<{ authorization: string | null }>(); + const response = await client.get('/echo-headers').asJson<{ authorization: string | null }>().execute(); expect(response.data?.authorization).toBe('Bearer instance-token'); }); @@ -349,7 +350,7 @@ describe.each(adapters)('HttpClient.$name — hooks', ({ create }) => { headers: { ...config.headers, Authorization: 'Bearer override-token' } })); - const response = await client.get('/echo-headers').execute<{ authorization: string | null }>(); + const response = await client.get('/echo-headers').asJson<{ authorization: string | null }>().execute(); expect(response.data?.authorization).toBe('Bearer override-token'); }); @@ -365,7 +366,7 @@ describe.each(adapters)('HttpClient.$name — hooks', ({ create }) => { expect(chained).toBe(client); - const response = await client.get('/items').execute<{ chained: boolean }>(); + const response = await client.get('/items').asJson<{ chained: boolean }>().execute(); expect(response.data).toEqual({ chained: true }); }); @@ -373,7 +374,7 @@ describe.each(adapters)('HttpClient.$name — hooks', ({ create }) => { test('applies instance hooks to subsequent requests only', async () => { const client = createClient(); - const before = await client.get('/echo-headers').execute<{ authorization: string | null }>(); + const before = await client.get('/echo-headers').asJson<{ authorization: string | null }>().execute(); expect(before.data?.authorization).toBeNull(); @@ -382,7 +383,7 @@ describe.each(adapters)('HttpClient.$name — hooks', ({ create }) => { headers: { ...config.headers, Authorization: 'Bearer late-token' } })); - const after = await client.get('/echo-headers').execute<{ authorization: string | null }>(); + const after = await client.get('/echo-headers').asJson<{ authorization: string | null }>().execute(); expect(after.data?.authorization).toBe('Bearer late-token'); }); diff --git a/services/http-client/test/core/HttpRequestBuilder.tests-d.ts b/services/http-client/test/core/HttpRequestBuilder.tests-d.ts index ed094a5d..7ae2df82 100644 --- a/services/http-client/test/core/HttpRequestBuilder.tests-d.ts +++ b/services/http-client/test/core/HttpRequestBuilder.tests-d.ts @@ -12,14 +12,39 @@ const executor: THttpRequestExecutor = (config: IHttpRequestConfig): Promise< }); describe('HttpRequestBuilder types', () => { - it('preserves the response type', () => { + 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<{ id: number }>()).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.asBlob().execute()).toEqualTypeOf>>(); }); it('allows body calls for every method at compile time', () => { diff --git a/services/http-client/test/core/HttpRequestBuilder.tests.ts b/services/http-client/test/core/HttpRequestBuilder.tests.ts index b6e82b2f..b75d9051 100644 --- a/services/http-client/test/core/HttpRequestBuilder.tests.ts +++ b/services/http-client/test/core/HttpRequestBuilder.tests.ts @@ -8,7 +8,6 @@ import { THttpMethod, THttpParams, THttpRequestExecutor, - THttpResponseType, TRequestBuilderErrorCode } from '../../src/types'; @@ -51,7 +50,7 @@ describe('HttpRequestBuilder', () => { .timeout(0) .withCredentials(true) .bearer('token') - .responseType(HTTP_RESPONSE_TYPES.JSON); + .asJson(); expect(initialBuilder.build()).toEqual({ method: HTTP_METHODS.POST, url: '/items' }); expect(configuredBuilder).not.toBe(initialBuilder); @@ -110,10 +109,17 @@ describe('HttpRequestBuilder', () => { const configs: IHttpRequestConfig[] = []; const builder = new HttpRequestBuilder(createExecutor(configs), HTTP_METHODS.GET, '/items').param('page', '1'); - const response = await builder.execute<{ id: number }>(); + 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' } }]); + expect(configs).toEqual([ + { + method: HTTP_METHODS.GET, + url: '/items', + params: { page: '1' }, + responseType: HTTP_RESPONSE_TYPES.JSON + } + ]); expect(response.config).toBe(configs[0]); }); @@ -213,11 +219,6 @@ describe('HttpRequestBuilder', () => { name: 'bearer token', action: (builder: HttpRequestBuilder) => builder.bearer(' '), code: REQUEST_BUILDER_ERROR_CODES.INVALID_BEARER_TOKEN - }, - { - name: 'response type', - action: (builder: HttpRequestBuilder) => builder.responseType('invalid' as THttpResponseType), - code: REQUEST_BUILDER_ERROR_CODES.INVALID_RESPONSE_TYPE } ])('validates $name', ({ action, code }) => { const builder = new HttpRequestBuilder(createExecutor(), HTTP_METHODS.POST, '/items'); From ed6b87a15a76857ce5c914609f585d82a86880d5 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Tue, 25 Aug 2026 14:27:00 +0500 Subject: [PATCH 53/60] feat(http): type of error --- services/http-client/DECISIONS.md | 64 ++++++++++++++++++- services/http-client/README.md | 21 +++++- services/http-client/TODO.md | 14 ++-- .../__handlers__/HttpClient.GET.handlers.ts | 14 ++++ .../test/core/HttpClient.GET.tests.ts | 39 ++++++++++- .../test/errors/errorGuards.tests-d.ts | 15 +++++ 6 files changed, 152 insertions(+), 15 deletions(-) diff --git a/services/http-client/DECISIONS.md b/services/http-client/DECISIONS.md index 88536630..a86dc7a8 100644 --- a/services/http-client/DECISIONS.md +++ b/services/http-client/DECISIONS.md @@ -145,7 +145,7 @@ Fetch- и XHR-адаптеры могут разобрать тело ошибк ### Когда пересматривать -При необходимости гарантировать схему тела ошибки во время выполнения, поддержать разные схемы для разных статусов или перейти к API с пользовательским валидатором данных. +При необходимости гарантировать схему тела ошибки во время выполнения, поддержать разные схемы для разных статусов или перейти к API с пользовательским валидатором данных. Последний вариант принят в D-009. ## D-005 — Оркестрация повторных запросов в отдельном классе @@ -238,8 +238,68 @@ Generic-параметр JSON переносится в `asJson()`. Мето - `asText()`, `asBlob()`, `asArrayBuffer()` и `asStream()` не принимают generic. - Адаптеры продолжают декодировать тело до завершения `execute()`. - Ошибки декодирования продолжают проходить через существующую модель `ParseError` и response hooks. -- Тип тела `HttpResponseError` не связан с типом успешного ответа и проектируется отдельно в P1-1. +- Тип тела `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` вместо исключений. diff --git a/services/http-client/README.md b/services/http-client/README.md index 0be6be88..1414a393 100644 --- a/services/http-client/README.md +++ b/services/http-client/README.md @@ -140,15 +140,32 @@ const response = await httpClient.get('/profile').withCredentials(true).asJson; +} + +function isValidationError(data: unknown): data is IValidationError { + if (typeof data !== 'object' || data === null) { + return false; + } + + const candidate = data as Partial; + + return typeof candidate.message === 'string' && typeof candidate.errors === 'object'; +} + try { await httpClient.get('/users/1').asJson().execute(); } catch (error) { - if (isHttpResponseError(error)) { - console.error(error.status, error.data); + 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 можно передать в конструктор или назначить методами клиента: diff --git a/services/http-client/TODO.md b/services/http-client/TODO.md index 169dd486..b0da3596 100644 --- a/services/http-client/TODO.md +++ b/services/http-client/TODO.md @@ -8,12 +8,6 @@ README создан и описывает публичный API, адаптеры, приоритеты конфигурации, CORS, body, ошибки и hooks. Перед первым publish остаются changeset и повышение версии `0.0.1` → `0.1.0`. -## P1 - -### P1-1 — Типизировать тело HTTP-ошибки - -`HttpResponseError` уже параметризован, но адаптеры создают его с `unknown`, а guard не позволяет вызывающему коду указать ожидаемый тип. Вариант с доверенным generic-guard, например `isHttpResponseError(error)`, зафиксирован в D-004 со статусом «не принято». После выбора контракта нужны явные сценарии для `400` и `422` и type-тесты. - ## P2 ### P2-1 — Модернизация package.json @@ -57,10 +51,9 @@ README создан и описывает публичный API, адаптер ## Рекомендуемый порядок 1. P0: changeset + версия -2. P1: typed errors -3. P2: package.json модернизация вместе с релизной подготовкой -4. P2: retry / progress / validateStatus по продуктовым нуждам -5. P2–P3: DX и полировка фоном +2. P2: package.json модернизация вместе с релизной подготовкой +3. P2: retry / progress / validateStatus по продуктовым нуждам +4. P2–P3: DX и полировка фоном ## Закрыто @@ -85,3 +78,4 @@ README создан и описывает публичный API, адаптер 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-тестами. diff --git a/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts index 80ea5cde..703d9dcb 100644 --- a/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts +++ b/services/http-client/test/__handlers__/HttpClient.GET.handlers.ts @@ -53,6 +53,20 @@ export const handlers = [ ); }), + 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/core/HttpClient.GET.tests.ts b/services/http-client/test/core/HttpClient.GET.tests.ts index 098987df..fd92f331 100644 --- a/services/http-client/test/core/HttpClient.GET.tests.ts +++ b/services/http-client/test/core/HttpClient.GET.tests.ts @@ -3,7 +3,7 @@ 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, ParseError } from '../../src/errors'; +import { HttpResponseError, isHttpResponseError, ParseError } from '../../src/errors'; import { IHttpClientAdapter } from '../../src/types'; import { handlers } from '../__handlers__/HttpClient.GET.handlers'; import { BASE_URL } from '../__fixtures__'; @@ -20,6 +20,21 @@ const adapters: Array<{ name: string; create: () => IHttpClientAdapter }> = [ { 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({ @@ -229,6 +244,28 @@ describe.each(adapters)('HttpClient.$name — GET', ({ name, create }) => { } }); + 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(); diff --git a/services/http-client/test/errors/errorGuards.tests-d.ts b/services/http-client/test/errors/errorGuards.tests-d.ts index 0426b05e..3e5ac34b 100644 --- a/services/http-client/test/errors/errorGuards.tests-d.ts +++ b/services/http-client/test/errors/errorGuards.tests-d.ts @@ -19,6 +19,15 @@ import { } 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'); @@ -29,6 +38,12 @@ describe('error guards', () => { 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)) { From bbb75f51f7087961a36b9467413bcf1460b9dcbe Mon Sep 17 00:00:00 2001 From: abrosimov Date: Tue, 25 Aug 2026 15:40:10 +0500 Subject: [PATCH 54/60] feat(http): handle uploading and downloading progress --- services/http-client/DECISIONS.md | 99 +++++++++++ services/http-client/README.md | 33 +++- services/http-client/TODO.md | 18 +- .../http-client/src/adapters/XhrAdapter.ts | 15 +- .../src/asserts/assertValidAdapter.ts | 17 ++ .../asserts/assertValidHttpClientOptions.ts | 16 +- .../asserts/assertValidXhrAdapterOptions.ts | 10 +- services/http-client/src/asserts/index.ts | 1 + services/http-client/src/core/HttpClient.ts | 16 +- .../src/types/XhrAdapterOptions.ts | 3 + .../test/adapters/AdapterOptions.tests.ts | 166 +++++++++++++++++- .../test/core/HttpClient.withAdapter.tests.ts | 110 ++++++++++++ .../test/types/AdapterOptions.tests-d.ts | 21 ++- 13 files changed, 491 insertions(+), 34 deletions(-) create mode 100644 services/http-client/src/asserts/assertValidAdapter.ts create mode 100644 services/http-client/test/core/HttpClient.withAdapter.tests.ts diff --git a/services/http-client/DECISIONS.md b/services/http-client/DECISIONS.md index a86dc7a8..15b13a23 100644 --- a/services/http-client/DECISIONS.md +++ b/services/http-client/DECISIONS.md @@ -303,3 +303,102 @@ try { ### Когда пересматривать При появлении декларативного контракта 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, credentials и четыре текущих hook наследуются на момент вызова. +- Исходный и scoped-клиенты независимо заменяют hooks через существующие методы. +- Переданный адаптер обязателен и проверяется тем же `assertValidAdapter`, что и настройка конструктора. +- Передача `undefined` не означает возврат к FetchAdapter и считается ошибкой. +- Builder остаётся транспортно-независимым, а `execute()` сохраняет единственный контракт выполнения. + +### Когда пересматривать + +При необходимости менять не только адаптер, но и отдельные defaults одним вызовом, либо при появлении общего API создания дочерних клиентов с частичным override конфигурации. diff --git a/services/http-client/README.md b/services/http-client/README.md index 1414a393..4e95b79b 100644 --- a/services/http-client/README.md +++ b/services/http-client/README.md @@ -94,12 +94,41 @@ const httpClient = new HttpClient({ adapter: new XhrAdapter({ mimeType: 'application/json', timeout: 10_000, - withCredentials: true + withCredentials: true, + onDownloadProgress: (event, config) => { + console.log(config.url, event.loaded, event.total); + } }) }); ``` -XHR полезен для сценариев, которым нужны возможности `XMLHttpRequest`. Текущий `asStream()` для XHR не является настоящим сетевым стримом: полученный текст накапливается в памяти. +XHR полезен для сценариев, которым нужны возможности `XMLHttpRequest`. `onDownloadProgress` и `onUploadProgress` получают нативный `ProgressEvent` и итоговую конфигурацию запроса. События передаются без throttling; если `lengthComputable === false`, значение `total` нельзя считать достоверным. + +Callbacks задаются на весь экземпляр адаптера. Для изолированной загрузки можно создать 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 listener подключается только при наличии `onUploadProgress` и фактического тела запроса. Для cross-origin запроса такая подписка принудительно включает CORS preflight согласно [спецификации XMLHttpRequest](), поэтому сервер должен корректно обрабатывать `OPTIONS`. FetchAdapter не предоставляет стандартный upload progress. + +Текущий `asStream()` для XHR не является настоящим сетевым стримом: полученный текст накапливается в памяти. Download progress и stream могут использоваться одновременно. ## Приоритет конфигурации diff --git a/services/http-client/TODO.md b/services/http-client/TODO.md index b0da3596..d1892599 100644 --- a/services/http-client/TODO.md +++ b/services/http-client/TODO.md @@ -14,10 +14,6 @@ README создан и описывает публичный API, адаптер Сборка только CJS: нет `exports`-map (dual ESM/CJS для Vite-потребителей), `sideEffects: false`, `engines` (Node 20). Без ESM-входа недоступен tree-shaking. -### P2-2 — Добавить progress API для XHR - -Выделить публичный контракт для `onDownloadProgress` и `onUploadProgress`. Нужно документировать, что upload-progress является преимуществом XHR и не имеет стандартного аналога в Fetch. - ### P2-3 — Добавить `validateStatus` Сейчас любой статус вне 2xx → `HttpResponseError`. Нужна опция `validateStatus?: (status) => boolean` (client/request), чтобы 404/304 и т.п. можно было считать успехом. @@ -40,10 +36,6 @@ README создан и описывает публичный API, адаптер Сейчас на каждую фазу можно назначить лишь один хук, а следующий вызов заменяет предыдущий. При совместном использовании auth, tracing и логирования потребуется композиция хуков с предсказуемым порядком и правилами восстановления. -### P3-2 — Convenience-методы builder - -Опциональный сахар: `json()`, `acceptJson()`, `multipart()` поверх headers. - ### P3-3 — Чистка конфигурации `.npmignore` избыточен при `files: ["dist"]` — удалить. Поправить команду запуска тестов пакета в корневом AGENTS.md (`vitest run --root ../../ --project ` не работает; рабочий вариант — `npm test -w @byndyusoft-ui/http-client`). @@ -52,9 +44,15 @@ README создан и описывает публичный API, адаптер 1. P0: changeset + версия 2. P2: package.json модернизация вместе с релизной подготовкой -3. P2: retry / progress / validateStatus по продуктовым нуждам +3. P2: retry / validateStatus по продуктовым нуждам 4. P2–P3: DX и полировка фоном +## Не планируется + +### P3-2 — Convenience-методы builder + +Явно не берём в работу как необязательный синтаксический сахар. Для настройки заголовков достаточно `header()` и `headers()`, а решение можно пересмотреть только при появлении подтверждённых повторяющихся сценариев. Причины зафиксированы в D-010. + ## Закрыто 1. Модель ошибок + guards (`HttpResponseError`, `ParseError`, `cause`/`config`) @@ -79,3 +77,5 @@ README создан и описывает публичный API, адаптер 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. diff --git a/services/http-client/src/adapters/XhrAdapter.ts b/services/http-client/src/adapters/XhrAdapter.ts index 391db66b..b8609eb4 100644 --- a/services/http-client/src/adapters/XhrAdapter.ts +++ b/services/http-client/src/adapters/XhrAdapter.ts @@ -286,11 +286,13 @@ function prepareXhrRequest(config: IHttpRequestConfig, options: IXhrAdapterOptio 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; const isSuccessful = (): boolean => xhr.status >= 200 && xhr.status < 300; @@ -320,9 +322,18 @@ function configureXhrEventHandlers( resolveStream(); } }; + } - xhr.onprogress = () => { + 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); }; } @@ -392,7 +403,7 @@ export class XhrAdapter implements IHttpClientAdapter { try { request = prepareXhrRequest(resolvedConfig, this.options); - configureXhrEventHandlers(request, resolvedConfig, resolve, reject); + configureXhrEventHandlers(request, resolvedConfig, this.options, resolve, reject); request.xhr.send(request.body); } catch (error) { request?.cleanup(); 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/assertValidHttpClientOptions.ts b/services/http-client/src/asserts/assertValidHttpClientOptions.ts index 733a6230..def11058 100644 --- a/services/http-client/src/asserts/assertValidHttpClientOptions.ts +++ b/services/http-client/src/asserts/assertValidHttpClientOptions.ts @@ -1,6 +1,7 @@ import { REQUEST_BUILDER_ERROR_CODES } from '../constants'; import { RequestBuilderError } from '../errors'; -import { IHttpClientAdapter, IHttpClientOptions } from '../types'; +import { IHttpClientOptions } from '../types'; +import { assertValidAdapter } from './assertValidAdapter'; import { assertValidBaseUrl } from './assertValidBaseUrl'; import { assertValidHeaders } from './assertValidHeaders'; import { assertValidParams } from './assertValidParams'; @@ -8,19 +9,6 @@ import { assertValidTimeout } from './assertValidTimeout'; import { assertValidWithCredentials } from './assertValidWithCredentials'; import { isRecord } from './isRecord'; -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 - ); - } -} - 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); diff --git a/services/http-client/src/asserts/assertValidXhrAdapterOptions.ts b/services/http-client/src/asserts/assertValidXhrAdapterOptions.ts index 31b2ff15..5b475a4e 100644 --- a/services/http-client/src/asserts/assertValidXhrAdapterOptions.ts +++ b/services/http-client/src/asserts/assertValidXhrAdapterOptions.ts @@ -16,7 +16,7 @@ export function assertValidXhrAdapterOptions(options: unknown): asserts options throwInvalidOptions('XHR adapter options must be an object'); } - const { mimeType, responseType, timeout, withCredentials } = options; + 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'); @@ -33,4 +33,12 @@ export function assertValidXhrAdapterOptions(options: unknown): asserts options 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/index.ts b/services/http-client/src/asserts/index.ts index 6bad84a9..807f7d30 100644 --- a/services/http-client/src/asserts/index.ts +++ b/services/http-client/src/asserts/index.ts @@ -1,4 +1,5 @@ export * from './assertValidBody'; +export * from './assertValidAdapter'; export * from './assertValidFetchAdapterOptions'; export * from './assertNonBlankString'; export * from './assertValidBaseUrl'; diff --git a/services/http-client/src/core/HttpClient.ts b/services/http-client/src/core/HttpClient.ts index 386c7d75..aff053d2 100644 --- a/services/http-client/src/core/HttpClient.ts +++ b/services/http-client/src/core/HttpClient.ts @@ -1,5 +1,5 @@ import { HTTP_METHODS } from '../constants'; -import { assertValidHttpClientOptions, assertValidRequestConfig } from '../asserts'; +import { assertValidAdapter, assertValidHttpClientOptions, assertValidRequestConfig } from '../asserts'; import { FetchAdapter } from '../adapters'; import { THttpMethod, @@ -45,6 +45,20 @@ export class HttpClient { }; } + /** 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; diff --git a/services/http-client/src/types/XhrAdapterOptions.ts b/services/http-client/src/types/XhrAdapterOptions.ts index 1ad50ba6..1b7d665d 100644 --- a/services/http-client/src/types/XhrAdapterOptions.ts +++ b/services/http-client/src/types/XhrAdapterOptions.ts @@ -1,4 +1,5 @@ import { THttpResponseType } from './HttpResponseType'; +import { IHttpRequestConfig } from './HttpRequestConfig'; /** Settings that are supported only by the XMLHttpRequest transport. */ export interface IXhrAdapterOptions { @@ -6,4 +7,6 @@ export interface IXhrAdapterOptions { responseType?: THttpResponseType; timeout?: number; withCredentials?: boolean; + onDownloadProgress?: (event: ProgressEvent, config: Readonly) => void; + onUploadProgress?: (event: ProgressEvent, config: Readonly) => void; } diff --git a/services/http-client/test/adapters/AdapterOptions.tests.ts b/services/http-client/test/adapters/AdapterOptions.tests.ts index 34e8f426..d8fc16d9 100644 --- a/services/http-client/test/adapters/AdapterOptions.tests.ts +++ b/services/http-client/test/adapters/AdapterOptions.tests.ts @@ -8,16 +8,22 @@ import { IFetchAdapterOptions, IXhrAdapterOptions, TRequestBuilderErrorCode } fr function createMockXhr(response: unknown = 'response'): { xhr: XMLHttpRequest; overrideMimeType: ReturnType; + setReadyState(value: number): void; + setResponseText(value: string): void; } { const overrideMimeType = vi.fn(); - const xhr = { + 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, @@ -29,13 +35,23 @@ function createMockXhr(response: unknown = 'response'): { abort: vi.fn(), getAllResponseHeaders: vi.fn(() => ''), getResponseHeader: vi.fn(() => null) - } as unknown as XMLHttpRequest; + }; + const xhr = state as unknown as XMLHttpRequest; xhr.send = vi.fn(() => { xhr.onload?.(new ProgressEvent('load')); }); - return { xhr, overrideMimeType }; + return { + xhr, + overrideMimeType, + setReadyState(value) { + state.readyState = value; + }, + setResponseText(value) { + state.responseText = value; + } + }; } function expectRequestBuilderError(action: () => unknown, code: TRequestBuilderErrorCode): void { @@ -172,6 +188,140 @@ describe('adapter constructor options', () => { } }); + 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', @@ -207,6 +357,16 @@ describe('adapter constructor options', () => { 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.withAdapter.tests.ts b/services/http-client/test/core/HttpClient.withAdapter.tests.ts new file mode 100644 index 00000000..bb6a060b --- /dev/null +++ b/services/http-client/test/core/HttpClient.withAdapter.tests.ts @@ -0,0 +1,110 @@ +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 client = new HttpClient({ + adapter: original.adapter, + baseUrl: 'https://api.example.test', + headers: { 'X-Default': 'default' }, + params: { locale: 'ru' }, + timeout: 1000, + 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, + 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/types/AdapterOptions.tests-d.ts b/services/http-client/test/types/AdapterOptions.tests-d.ts index 43194a27..2b4e0a01 100644 --- a/services/http-client/test/types/AdapterOptions.tests-d.ts +++ b/services/http-client/test/types/AdapterOptions.tests-d.ts @@ -1,4 +1,11 @@ -import { FetchAdapter, IFetchAdapterOptions, IXhrAdapterOptions, XhrAdapter } from '../../src'; +import { + FetchAdapter, + HttpClient, + IFetchAdapterOptions, + IHttpRequestConfig, + IXhrAdapterOptions, + XhrAdapter +} from '../../src'; describe('adapter option types', () => { it('accepts supported Fetch and XHR options', () => { @@ -16,11 +23,21 @@ describe('adapter option types', () => { mimeType: 'application/json', responseType: 'text', timeout: 1000, - withCredentials: true + 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'>(); }); }); From 1492c730a01cc98725ff95472bcd48039fe90a28 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Tue, 25 Aug 2026 16:08:42 +0500 Subject: [PATCH 55/60] feat(http): clean up --- services/http-client/.npmignore | 8 -------- services/http-client/TODO.md | 7 ++----- 2 files changed, 2 insertions(+), 13 deletions(-) delete mode 100644 services/http-client/.npmignore diff --git a/services/http-client/.npmignore b/services/http-client/.npmignore deleted file mode 100644 index 11dd0919..00000000 --- a/services/http-client/.npmignore +++ /dev/null @@ -1,8 +0,0 @@ -src -test -tsconfig.json -tsconfig.build.json -vitest.config.mjs -.turbo -node_modules -*.tgz diff --git a/services/http-client/TODO.md b/services/http-client/TODO.md index d1892599..b9164e6b 100644 --- a/services/http-client/TODO.md +++ b/services/http-client/TODO.md @@ -36,10 +36,6 @@ README создан и описывает публичный API, адаптер Сейчас на каждую фазу можно назначить лишь один хук, а следующий вызов заменяет предыдущий. При совместном использовании auth, tracing и логирования потребуется композиция хуков с предсказуемым порядком и правилами восстановления. -### P3-3 — Чистка конфигурации - -`.npmignore` избыточен при `files: ["dist"]` — удалить. Поправить команду запуска тестов пакета в корневом AGENTS.md (`vitest run --root ../../ --project ` не работает; рабочий вариант — `npm test -w @byndyusoft-ui/http-client`). - ## Рекомендуемый порядок 1. P0: changeset + версия @@ -66,7 +62,7 @@ README создан и описывает публичный API, адаптер 9. Default `FetchAdapter`, `withCredentials` (client / builder / оба адаптера) 10. Adapter options (`FetchAdapterOptions`, `XhrAdapterOptions`) 11. Удалены устаревшие закомментированные методы из `HttpRequestBuilder` -12. Publish hygiene: `files: ["dist"]`, `.npmignore` +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'`. @@ -79,3 +75,4 @@ README создан и описывает публичный API, адаптер 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 `. From 42d1fa54f5eb1ab494a4fd77d848ebd183c45589 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Tue, 25 Aug 2026 16:08:55 +0500 Subject: [PATCH 56/60] feat(http): update agents.md --- AGENTS.md | 58 +++++++++++++++++++++++++++---------------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9e44d965..35f7d73a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,13 +4,13 @@ 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 | +| 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/`. @@ -39,8 +39,8 @@ npm run publish # changeset publish to npm ### Run a single package's tests ```bash -vitest run --root ../../ --project -# Example: vitest run --root ../../ --project @byndyusoft-ui/use-timeout +npm test -w +# Example: npm test -w @byndyusoft-ui/use-timeout ``` The `` is the npm `name` field from the package's `package.json`. @@ -62,32 +62,32 @@ Generated packages include `package.json`, `tsconfig.json`, `src/` with boilerpl ## 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 +- 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 +- 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 +- 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`) \ No newline at end of file +- 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`) From 98b4a22a58b3563d06b93f7718a549b44fdd1f0e Mon Sep 17 00:00:00 2001 From: abrosimov Date: Tue, 25 Aug 2026 16:53:23 +0500 Subject: [PATCH 57/60] feat(http): prepare to publish --- services/http-client/CHANGELOG.md | 14 ++++ services/http-client/DECISIONS.md | 38 +++++++++ services/http-client/README.md | 12 +++ services/http-client/TODO.md | 18 +---- services/http-client/package.json | 25 ++++-- services/http-client/rollup.config.mjs | 38 +++++++++ .../http-client/scripts/verify-package.mjs | 81 +++++++++++++++++++ services/http-client/tsconfig.build.json | 8 +- 8 files changed, 211 insertions(+), 23 deletions(-) create mode 100644 services/http-client/CHANGELOG.md create mode 100644 services/http-client/rollup.config.mjs create mode 100644 services/http-client/scripts/verify-package.mjs 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 index 15b13a23..bbacf4b2 100644 --- a/services/http-client/DECISIONS.md +++ b/services/http-client/DECISIONS.md @@ -402,3 +402,41 @@ Scoped-клиент позволяет локально выбрать тран ### Когда пересматривать При необходимости менять не только адаптер, но и отдельные 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. diff --git a/services/http-client/README.md b/services/http-client/README.md index 4e95b79b..acb90ff6 100644 --- a/services/http-client/README.md +++ b/services/http-client/README.md @@ -8,6 +8,18 @@ HTTP-клиент с неизменяемым builder, адаптерами Fetc 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`: diff --git a/services/http-client/TODO.md b/services/http-client/TODO.md index b9164e6b..d87f316b 100644 --- a/services/http-client/TODO.md +++ b/services/http-client/TODO.md @@ -2,18 +2,8 @@ Связанные архитектурные решения зафиксированы в [DECISIONS.md](./DECISIONS.md). -## P0 - -### P0-1 — Релизная гигиена: changeset + версия - -README создан и описывает публичный API, адаптеры, приоритеты конфигурации, CORS, body, ошибки и hooks. Перед первым publish остаются changeset и повышение версии `0.0.1` → `0.1.0`. - ## P2 -### P2-1 — Модернизация package.json - -Сборка только CJS: нет `exports`-map (dual ESM/CJS для Vite-потребителей), `sideEffects: false`, `engines` (Node 20). Без ESM-входа недоступен tree-shaking. - ### P2-3 — Добавить `validateStatus` Сейчас любой статус вне 2xx → `HttpResponseError`. Нужна опция `validateStatus?: (status) => boolean` (client/request), чтобы 404/304 и т.п. можно было считать успехом. @@ -38,10 +28,8 @@ README создан и описывает публичный API, адаптер ## Рекомендуемый порядок -1. P0: changeset + версия -2. P2: package.json модернизация вместе с релизной подготовкой -3. P2: retry / validateStatus по продуктовым нуждам -4. P2–P3: DX и полировка фоном +1. P2: retry / validateStatus по продуктовым нуждам +2. P2–P3: DX и полировка фоном ## Не планируется @@ -76,3 +64,5 @@ README создан и описывает публичный API, адаптер 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 не выполнялась. diff --git a/services/http-client/package.json b/services/http-client/package.json index bd5b158c..8de7e42c 100644 --- a/services/http-client/package.json +++ b/services/http-client/package.json @@ -1,6 +1,6 @@ { "name": "@byndyusoft-ui/http-client", - "version": "0.0.1", + "version": "0.1.0", "description": "Byndyusoft UI HTTP Client Service", "keywords": [ "byndyusoft", @@ -10,20 +10,35 @@ "author": "Byndyusoft Frontend Developer ", "homepage": "https://github.com/Byndyusoft/ui/tree/master/services/http-client#readme", "license": "Apache-2.0", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "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", + "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": "vitest run --config vitest.config.mjs --typecheck", + "test:package": "node scripts/verify-package.mjs" }, "bugs": { "url": "https://github.com/Byndyusoft/ui/issues" 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/tsconfig.build.json b/services/http-client/tsconfig.build.json index c32656bb..b5935de6 100644 --- a/services/http-client/tsconfig.build.json +++ b/services/http-client/tsconfig.build.json @@ -2,10 +2,10 @@ "extends": "./tsconfig.json", "compilerOptions": { "declaration": true, - "declarationDir": "dist", - "outDir": "dist", - "module": "commonjs" + "declarationDir": "dist/declarations", + "emitDeclarationOnly": true, + "rootDir": "src" }, - "include": ["../../types.d.ts", "src"], + "include": ["src"], "exclude": ["node_modules"] } From 6d73e049df483670b16846783ba7465f8aa9a5b4 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Tue, 25 Aug 2026 16:53:36 +0500 Subject: [PATCH 58/60] feat(http): prepare to publish --- package-lock.json | 95 +++++++++++++++++++++++++++++++++++++++++++---- package.json | 1 + 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index a190fe93..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", @@ -2737,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", @@ -2748,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" }, @@ -11524,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": { @@ -14213,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", @@ -17198,6 +17276,9 @@ "license": "Apache-2.0", "devDependencies": { "msw": "^2.4.9" + }, + "engines": { + "node": ">=20" } }, "services/local-storage": { 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", From 3dd83667ab2e13ba60d915fdc5a933a6dd4d279c Mon Sep 17 00:00:00 2001 From: abrosimov Date: Tue, 25 Aug 2026 18:41:46 +0500 Subject: [PATCH 59/60] feat(http): update readme.md --- services/http-client/README.md | 252 ++++++++++++++++++++++++++++++--- services/http-client/TODO.md | 1 + 2 files changed, 231 insertions(+), 22 deletions(-) diff --git a/services/http-client/README.md b/services/http-client/README.md index acb90ff6..23137223 100644 --- a/services/http-client/README.md +++ b/services/http-client/README.md @@ -20,6 +20,13 @@ 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`: @@ -50,7 +57,44 @@ const user = response.data; | `asArrayBuffer()` | `ArrayBuffer \| undefined` | низкоуровневая бинарная обработка | | `asStream()` | `ReadableStream \| undefined` | потоковое чтение ответа | -Вызов без селектора допустим и возвращает `IHttpResponse`. Generic `asJson()` описывает ожидаемую схему и не проверяет данные во время выполнения. +Вызов без селектора допустим и возвращает `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, + withCredentials: true +}); +``` + +| Настройка | Назначение | Значение по умолчанию | +| ----------------- | ---------------------------------------------------- | --------------------- | +| `adapter` | транспорт, реализующий `IHttpClientAdapter` | новый `FetchAdapter` | +| `baseUrl` | базовая часть относительных URL | отсутствует | +| `headers` | заголовки всех запросов | отсутствуют | +| `params` | query-параметры всех запросов | отсутствуют | +| `timeout` | таймаут в миллисекундах | не задан на клиенте | +| `withCredentials` | отправка credentials | зависит от адаптера | +| `onRequest` | преобразование итоговой конфигурации перед адаптером | отсутствует | +| `onRequestError` | восстановление после ошибки request-hook | отсутствует | +| `onResponse` | преобразование успешного ответа | отсутствует | +| `onResponseError` | обработка или восстановление после ошибки ответа | отсутствует | ## Построение запроса @@ -70,14 +114,73 @@ 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` отключает также унаследованный таймаут | +| `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-совместимые значения, строку, `FormData`, `URLSearchParams`, `Blob`, `ArrayBuffer` и `ArrayBufferView`. +`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`. + +## Отмена и таймауты + +```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; + } +} +``` -- Обычный объект автоматически сериализуется в JSON; при отсутствии заголовка добавляется `Content-Type: application/json`. -- `body(null)` отправляет JSON `null`. -- `body(undefined)` синхронно выбрасывает `RequestBuilderError`. -- При передаче `FormData` заголовок `Content-Type` вручную задавать не следует: браузер добавит boundary. +`timeout()` принимает конечное неотрицательное число миллисекунд. Значение `0` отключает таймаут, включая заданный в `HttpClient` или `XhrAdapter`. Пользовательская отмена приводит к `AbortError`, истечение таймаута — к `TimeoutError` с фактическим значением в поле `timeout`. ## Адаптеры @@ -95,7 +198,20 @@ const httpClient = new HttpClient({ }); ``` -Fetch-специфичные параметры задаются в конструкторе адаптера. При `redirect: 'manual'` браузер может вернуть `opaque-redirect` со статусом `0`; продолжить такой редирект вручную нельзя. +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 @@ -116,7 +232,16 @@ const httpClient = new HttpClient({ XHR полезен для сценариев, которым нужны возможности `XMLHttpRequest`. `onDownloadProgress` и `onUploadProgress` получают нативный `ProgressEvent` и итоговую конфигурацию запроса. События передаются без throttling; если `lengthComputable === false`, значение `total` нельзя считать достоверным. -Callbacks задаются на весь экземпляр адаптера. Для изолированной загрузки можно создать scoped-клиент: +| Настройка | Назначение | +| -------------------- | --------------------------------------------------- | +| `mimeType` | переопределяет MIME type через `overrideMimeType()` | +| `responseType` | формат ответа по умолчанию для запросов без `as*()` | +| `timeout` | таймаут по умолчанию | +| `withCredentials` | credentials по умолчанию | +| `onDownloadProgress` | события загрузки ответа | +| `onUploadProgress` | события отправки тела | + +Обработчики задаются на весь экземпляр адаптера. Для изолированной загрузки можно создать scoped-клиент: ```ts interface IUploadResult { @@ -138,9 +263,35 @@ await uploadClient.post('/files').body(file).asJson().execute(); `withAdapter()` возвращает новый клиент со снимком текущих defaults и hooks. Исходный клиент и его адаптер не изменяются; последующая замена hooks в одном клиенте не влияет на другой. -Upload listener подключается только при наличии `onUploadProgress` и фактического тела запроса. Для cross-origin запроса такая подписка принудительно включает CORS preflight согласно [спецификации XMLHttpRequest](), поэтому сервер должен корректно обрабатывать `OPTIONS`. FetchAdapter не предоставляет стандартный upload progress. +Обработчик 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()) {} -Текущий `asStream()` для XHR не является настоящим сетевым стримом: полученный текст накапливается в памяти. Download progress и stream могут использоваться одновременно. + public request(config: IHttpRequestConfig): Promise> { + console.log(config.method, config.url); + + return this.inner.request(config); + } +} + +const httpClient = new HttpClient({ adapter: new LoggingAdapter() }); +``` ## Приоритет конфигурации @@ -150,7 +301,7 @@ Upload listener подключается только при наличии `onU 2. Настройки `HttpClient`. 3. Значения по умолчанию адаптера. -Специфичные для Fetch и XHR параметры задаются только в конструкторах соответствующих адаптеров. +Для XHR такой порядок применяется к `timeout`, `responseType` и `withCredentials`. Заголовки, params и `baseUrl` не имеют значений по умолчанию на уровне адаптера. Специфичные для Fetch и XHR параметры задаются только в конструкторах соответствующих адаптеров. ## Credentials и CORS @@ -164,19 +315,29 @@ const response = await httpClient.get('/profile').withCredentials(true).asJson; - return typeof candidate.message === 'string' && typeof candidate.errors === 'object'; + 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 { @@ -209,7 +381,16 @@ try { ## Hooks -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({ @@ -225,3 +406,30 @@ const httpClient = new HttpClient({ ``` Поддерживаются `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`, типы hooks и опций ошибок. + +Также экспортируются `HTTP_METHODS`, `HTTP_STATUS_CODES`, `HTTP_RESPONSE_TYPES` и `REQUEST_BUILDER_ERROR_CODES`. Внутренние asserts и utilities не входят в корневой публичный API. + +## Ограничения текущей версии + +- Успешными считаются только статусы 2xx. Пользовательский `validateStatus` пока не поддерживается. +- Встроенных повторных запросов нет; 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 index d87f316b..63aadbf9 100644 --- a/services/http-client/TODO.md +++ b/services/http-client/TODO.md @@ -66,3 +66,4 @@ 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, публичные типы и ограничения. From 749a034149f9123f41c27864a5ece3912a3e5b48 Mon Sep 17 00:00:00 2001 From: abrosimov Date: Wed, 26 Aug 2026 13:47:40 +0500 Subject: [PATCH 60/60] feat(http): validate status --- services/http-client/DECISIONS.md | 33 +++++++++- services/http-client/README.md | 42 +++++++++++-- services/http-client/TODO.md | 19 +++--- .../http-client/src/adapters/FetchAdapter.ts | 60 ++++++++++++------- .../http-client/src/adapters/XhrAdapter.ts | 38 ++++++++++-- services/http-client/src/adapters/index.ts | 2 +- .../asserts/assertValidHttpClientOptions.ts | 6 ++ .../src/asserts/assertValidParam.ts | 4 +- .../src/asserts/assertValidRequestConfig.ts | 19 +++++- .../src/asserts/assertValidValidateStatus.ts | 13 ++++ services/http-client/src/asserts/index.ts | 1 + .../src/constants/httpStatusCodes.ts | 2 +- .../src/constants/requestBuilderErrorCodes.ts | 1 + services/http-client/src/core/HttpClient.ts | 4 +- .../src/core/HttpRequestBuilder.ts | 10 ++++ services/http-client/src/core/index.ts | 2 +- services/http-client/src/index.ts | 2 +- .../src/types/HttpClientOptions.ts | 3 + services/http-client/src/types/HttpHeaders.ts | 2 +- services/http-client/src/types/HttpMethod.ts | 2 +- services/http-client/src/types/HttpParams.ts | 6 +- .../src/types/HttpRequestConfig.ts | 2 + .../http-client/src/types/ValidateStatus.ts | 2 + services/http-client/src/types/index.ts | 1 + services/http-client/src/utilities/index.ts | 1 + .../src/utilities/isStatusAccepted.ts | 6 ++ .../test/__fixtures__/constants.ts | 2 +- .../http-client/test/__fixtures__/index.ts | 2 +- .../test/core/HttpClient.constructor.tests.ts | 6 ++ .../core/HttpClient.defaultConfig.tests.ts | 44 ++++++++++++++ .../test/core/HttpClient.hooks.tests.ts | 10 ++++ .../test/core/HttpClient.withAdapter.tests.ts | 3 + .../test/core/HttpRequestBuilder.tests-d.ts | 12 +++- .../test/core/HttpRequestBuilder.tests.ts | 9 +++ .../test/utilities/isStatusAccepted.tests.ts | 20 +++++++ 35 files changed, 335 insertions(+), 56 deletions(-) create mode 100644 services/http-client/src/asserts/assertValidValidateStatus.ts create mode 100644 services/http-client/src/types/ValidateStatus.ts create mode 100644 services/http-client/src/utilities/isStatusAccepted.ts create mode 100644 services/http-client/test/utilities/isStatusAccepted.tests.ts diff --git a/services/http-client/DECISIONS.md b/services/http-client/DECISIONS.md index bbacf4b2..1f823523 100644 --- a/services/http-client/DECISIONS.md +++ b/services/http-client/DECISIONS.md @@ -393,7 +393,7 @@ Scoped-клиент позволяет локально выбрать тран ### Последствия - `withAdapter()` всегда возвращает новый экземпляр `HttpClient`. -- `baseUrl`, headers, params, timeout, credentials и четыре текущих hook наследуются на момент вызова. +- `baseUrl`, headers, params, timeout, `validateStatus`, credentials и четыре текущих hook наследуются на момент вызова. - Исходный и scoped-клиенты независимо заменяют hooks через существующие методы. - Переданный адаптер обязателен и проверяется тем же `assertValidAdapter`, что и настройка конструктора. - Передача `undefined` не означает возврат к FetchAdapter и считается ошибкой. @@ -440,3 +440,34 @@ ESM-вход позволяет Vite и другим современным сб ### Когда пересматривать При отказе от 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 index 23137223..5a65325e 100644 --- a/services/http-client/README.md +++ b/services/http-client/README.md @@ -79,6 +79,7 @@ const httpClient = new HttpClient({ headers: { Accept: 'application/json' }, params: { locale: 'ru' }, timeout: 10_000, + validateStatus: status => status >= 200 && status < 400, withCredentials: true }); ``` @@ -90,6 +91,7 @@ const httpClient = new HttpClient({ | `headers` | заголовки всех запросов | отсутствуют | | `params` | query-параметры всех запросов | отсутствуют | | `timeout` | таймаут в миллисекундах | не задан на клиенте | +| `validateStatus` | определяет успешность HTTP-статуса | статусы `200–299` | | `withCredentials` | отправка credentials | зависит от адаптера | | `onRequest` | преобразование итоговой конфигурации перед адаптером | отсутствует | | `onRequestError` | восстановление после ошибки request-hook | отсутствует | @@ -124,6 +126,7 @@ const response = await request.execute(); | `body(data)` | задаёт тело запроса | | `signal(signal)` | привязывает пользовательский `AbortSignal` | | `timeout(milliseconds)` | задаёт таймаут; `0` отключает также унаследованный таймаут | +| `validateStatus(predicate)` | определяет успешность статуса конкретного ответа | | `withCredentials(value)` | управляет credentials конкретного запроса | | `bearer(token)` | устанавливает `Authorization: Bearer ` | | `asJson()` и остальные `as*()` | выбирают формат чтения и тип успешного ответа | @@ -161,6 +164,34 @@ const response = await httpClient `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 @@ -293,6 +324,8 @@ class LoggingAdapter implements IHttpClientAdapter { const httpClient = new HttpClient({ adapter: new LoggingAdapter() }); ``` +`validateStatus` входит в конфигурацию запроса и исполняется транспортом. Пользовательский адаптер, который не делегирует выполнение Fetch- или XHR-адаптеру, должен самостоятельно применить предикат и сформировать `HttpResponseError` для отклонённого статуса. + ## Приоритет конфигурации Общие настройки разрешаются в следующем порядке: @@ -301,7 +334,7 @@ const httpClient = new HttpClient({ adapter: new LoggingAdapter() }); 2. Настройки `HttpClient`. 3. Значения по умолчанию адаптера. -Для XHR такой порядок применяется к `timeout`, `responseType` и `withCredentials`. Заголовки, params и `baseUrl` не имеют значений по умолчанию на уровне адаптера. Специфичные для Fetch и XHR параметры задаются только в конструкторах соответствующих адаптеров. +Предикат `validateStatus` запроса имеет приоритет над предикатом клиента; при отсутствии обоих используется диапазон `200–299`. Для XHR общий порядок применяется также к `timeout`, `responseType` и `withCredentials`. Заголовки, params и `baseUrl` не имеют значений по умолчанию на уровне адаптера. Специфичные для Fetch и XHR параметры задаются только в конструкторах соответствующих адаптеров. ## Credentials и CORS @@ -327,7 +360,7 @@ const response = await httpClient.get('/profile').withCredentials(true).asJson()` задаёт ожидаемый TypeScript-тип, но не проверяет схему данных во время выполнения. diff --git a/services/http-client/TODO.md b/services/http-client/TODO.md index 63aadbf9..0feb379a 100644 --- a/services/http-client/TODO.md +++ b/services/http-client/TODO.md @@ -4,18 +4,10 @@ ## P2 -### P2-3 — Добавить `validateStatus` - -Сейчас любой статус вне 2xx → `HttpResponseError`. Нужна опция `validateStatus?: (status) => boolean` (client/request), чтобы 404/304 и т.п. можно было считать успехом. - ### P2-4 — Спроектировать opt-in retry Согласно D-005, повторы будут реализованы в отдельном opt-in классе для оркестрации запросов, без встраивания retry-логики в `HttpClient`, адаптеры или hooks. Нужно спроектировать лимит попыток, exponential backoff, поддержку `Retry-After`, отмену через `AbortSignal`, перечень временных сетевых сбоев и статусов (`408`, `429`, часть `5xx`), а также безопасное поведение для мутаций. -### P2-5 — Зафиксировать edge-cases XHR stream - -Поведение abort/timeout после resolve stream и 4xx при `responseType: 'stream'` должно быть явным контрактом (reject promise vs `stream.error`) с тестами. Смежное: расходящаяся семантика timeout для STREAM — fetch-таймер живёт до получения заголовков, `xhr.timeout` покрывает всю загрузку; задокументировать или унифицировать. Также задокументировать, что XHR-stream не настоящий стрим: ответ целиком накапливается в `responseText`, что ограничивает размер стриминговых ответов по памяти. - ### P2-6 — Добавить браузерные интеграционные тесты Проверить в настоящих браузерах credentialed CORS, preflight, cookies с `SameSite`/`Secure`, redirects и `keepalive`. Тесты jsdom и mock-адаптеров не воспроизводят эти особенности платформы. Актуально после появления реальных пользователей credentials-API. @@ -28,9 +20,17 @@ ## Рекомендуемый порядок -1. P2: retry / validateStatus по продуктовым нуждам +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 @@ -67,3 +67,4 @@ 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/src/adapters/FetchAdapter.ts b/services/http-client/src/adapters/FetchAdapter.ts index ae0cc422..b59bfae9 100644 --- a/services/http-client/src/adapters/FetchAdapter.ts +++ b/services/http-client/src/adapters/FetchAdapter.ts @@ -15,7 +15,7 @@ import { THttpResponseType } from '../types'; import { assertValidFetchAdapterOptions } from '../asserts'; -import { buildUrl, getErrorMessage, mergeHeaders, prepareRequestBody } from '../utilities'; +import { buildUrl, getErrorMessage, isStatusAccepted, mergeHeaders, prepareRequestBody } from '../utilities'; function extractResponseHeaders(headers: Headers): THttpHeaders { const result: THttpHeaders = {}; @@ -110,6 +110,26 @@ function resolveCredentials( 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, @@ -178,8 +198,10 @@ export class FetchAdapter implements IHttpClientAdapter { throw new RequestPreparationError('Failed to prepare HTTP request', { cause: error, config }); } + let response: Response; + try { - const response = await fetch(request.fullUrl, { + response = await fetch(request.fullUrl, { ...this.options, method: config.method, headers: request.requestHeaders, @@ -187,8 +209,22 @@ export class FetchAdapter implements IHttpClientAdapter { 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; + } - if (!response.ok) { + try { + if (!statusAccepted) { let errorData: unknown; try { const text = await response.text(); @@ -221,23 +257,7 @@ export class FetchAdapter implements IHttpClientAdapter { config }; } catch (error) { - if (error instanceof HttpClientError) { - throw error; - } - - if (request.userSignal?.aborted) { - throw new AbortError('Request was aborted', { cause: request.userSignal.reason ?? error, config }); - } - - if (request.timeout && error instanceof DOMException && error.name === 'AbortError') { - throw new TimeoutError(`Request timed out after ${request.timeout}ms`, { - cause: error, - config, - timeout: request.timeout - }); - } - - throw new NetworkError(getErrorMessage(error, 'Network request failed'), { cause: error, config }); + 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 index b8609eb4..2dc9d344 100644 --- a/services/http-client/src/adapters/XhrAdapter.ts +++ b/services/http-client/src/adapters/XhrAdapter.ts @@ -15,7 +15,7 @@ import { THttpResponseType } from '../types'; import { assertValidXhrAdapterOptions } from '../asserts'; -import { buildUrl, getErrorMessage, mergeHeaders, prepareRequestBody } from '../utilities'; +import { buildUrl, getErrorMessage, isStatusAccepted, mergeHeaders, prepareRequestBody } from '../utilities'; function parseResponseHeaders(rawHeaders: string): THttpHeaders { const result: THttpHeaders = {}; @@ -295,7 +295,22 @@ function configureXhrEventHandlers( const { onDownloadProgress, onUploadProgress } = options; let responseStream: IXhrResponseStream | undefined; let streamResponseResolved = false; - const isSuccessful = (): boolean => xhr.status >= 200 && xhr.status < 300; + 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; @@ -319,7 +334,13 @@ function configureXhrEventHandlers( if (responseType === HTTP_RESPONSE_TYPES.STREAM) { xhr.onreadystatechange = () => { if (xhr.readyState === XMLHttpRequest.HEADERS_RECEIVED) { - resolveStream(); + try { + resolveStream(); + } catch (error) { + request.cleanup(); + reject(error); + xhr.abort(); + } } }; } @@ -340,7 +361,16 @@ function configureXhrEventHandlers( xhr.onload = async () => { request.cleanup(); - if (!isSuccessful()) { + let successful: boolean; + + try { + successful = isSuccessful(); + } catch (error) { + reject(error); + return; + } + + if (!successful) { reject(await createResponseError(xhr, config)); return; } diff --git a/services/http-client/src/adapters/index.ts b/services/http-client/src/adapters/index.ts index ca711918..1f4ec124 100644 --- a/services/http-client/src/adapters/index.ts +++ b/services/http-client/src/adapters/index.ts @@ -1,2 +1,2 @@ export { FetchAdapter } from './FetchAdapter'; -export { XhrAdapter } from './XhrAdapter'; \ No newline at end of file +export { XhrAdapter } from './XhrAdapter'; diff --git a/services/http-client/src/asserts/assertValidHttpClientOptions.ts b/services/http-client/src/asserts/assertValidHttpClientOptions.ts index def11058..6e9c8da5 100644 --- a/services/http-client/src/asserts/assertValidHttpClientOptions.ts +++ b/services/http-client/src/asserts/assertValidHttpClientOptions.ts @@ -7,6 +7,7 @@ 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 { @@ -27,6 +28,7 @@ export function assertValidHttpClientOptions(options: unknown): asserts options headers, params, timeout, + validateStatus, withCredentials, onRequest, onRequestError, @@ -54,6 +56,10 @@ export function assertValidHttpClientOptions(options: unknown): asserts options assertValidTimeout(timeout); } + if (validateStatus !== undefined) { + assertValidValidateStatus(validateStatus); + } + if (withCredentials !== undefined) { assertValidWithCredentials(withCredentials); } diff --git a/services/http-client/src/asserts/assertValidParam.ts b/services/http-client/src/asserts/assertValidParam.ts index 998d5bbd..17e152ca 100644 --- a/services/http-client/src/asserts/assertValidParam.ts +++ b/services/http-client/src/asserts/assertValidParam.ts @@ -14,7 +14,9 @@ function isValidParamValue(value: unknown): value is THttpParamValue { return true; } - return Array.isArray(value) && value.every(item => item === null || item === undefined || isValidParamPrimitive(item)); + return ( + Array.isArray(value) && value.every(item => item === null || item === undefined || isValidParamPrimitive(item)) + ); } export function assertValidParam(key: unknown, value: unknown): asserts value is THttpParamValue { diff --git a/services/http-client/src/asserts/assertValidRequestConfig.ts b/services/http-client/src/asserts/assertValidRequestConfig.ts index 0f931a0d..e6c13525 100644 --- a/services/http-client/src/asserts/assertValidRequestConfig.ts +++ b/services/http-client/src/asserts/assertValidRequestConfig.ts @@ -11,6 +11,7 @@ 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. */ @@ -19,7 +20,19 @@ export function assertValidRequestConfig(config: unknown): asserts config is IHt throw new RequestBuilderError('Request config must be an object', REQUEST_BUILDER_ERROR_CODES.INVALID_CONFIG); } - const { method, url, baseUrl, headers, params, data, signal, timeout, withCredentials, responseType } = config; + const { + method, + url, + baseUrl, + headers, + params, + data, + signal, + timeout, + validateStatus, + withCredentials, + responseType + } = config; assertValidMethod(method); assertValidUrl(url); @@ -44,6 +57,10 @@ export function assertValidRequestConfig(config: unknown): asserts config is IHt assertValidTimeout(timeout); } + if (validateStatus !== undefined) { + assertValidValidateStatus(validateStatus); + } + if (withCredentials !== undefined) { assertValidWithCredentials(withCredentials); } 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/index.ts b/services/http-client/src/asserts/index.ts index 807f7d30..d104d458 100644 --- a/services/http-client/src/asserts/index.ts +++ b/services/http-client/src/asserts/index.ts @@ -14,6 +14,7 @@ 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/constants/httpStatusCodes.ts b/services/http-client/src/constants/httpStatusCodes.ts index 082ce04d..41f8df8a 100644 --- a/services/http-client/src/constants/httpStatusCodes.ts +++ b/services/http-client/src/constants/httpStatusCodes.ts @@ -61,4 +61,4 @@ export const HTTP_STATUS_CODES = { LOOP_DETECTED: 508, NOT_EXTENDED: 510, NETWORK_AUTHENTICATION_REQUIRED: 511 -} as const; \ No newline at end of file +} as const; diff --git a/services/http-client/src/constants/requestBuilderErrorCodes.ts b/services/http-client/src/constants/requestBuilderErrorCodes.ts index 6639643d..db4668ca 100644 --- a/services/http-client/src/constants/requestBuilderErrorCodes.ts +++ b/services/http-client/src/constants/requestBuilderErrorCodes.ts @@ -16,6 +16,7 @@ export const REQUEST_BUILDER_ERROR_CODES = { 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 index aff053d2..1ad1e52a 100644 --- a/services/http-client/src/core/HttpClient.ts +++ b/services/http-client/src/core/HttpClient.ts @@ -20,7 +20,7 @@ export class HttpClient { private readonly adapter: IHttpClientAdapter; private readonly defaultConfig: Pick< IHttpRequestConfig, - 'baseUrl' | 'headers' | 'timeout' | 'withCredentials' | 'params' + 'baseUrl' | 'headers' | 'timeout' | 'validateStatus' | 'withCredentials' | 'params' >; private onRequestHook?: THttpRequestHook; private onRequestErrorHook?: THttpRequestErrorHook; @@ -41,6 +41,7 @@ export class HttpClient { headers: mergeHeaders(options.headers), params: mergeParams(options.params), timeout: options.timeout, + validateStatus: options.validateStatus, withCredentials: options.withCredentials }; } @@ -134,6 +135,7 @@ export class HttpClient { ...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) diff --git a/services/http-client/src/core/HttpRequestBuilder.ts b/services/http-client/src/core/HttpRequestBuilder.ts index 1ffef475..09111977 100644 --- a/services/http-client/src/core/HttpRequestBuilder.ts +++ b/services/http-client/src/core/HttpRequestBuilder.ts @@ -23,6 +23,7 @@ import { assertValidSignal, assertValidTimeout, assertValidUrl, + assertValidValidateStatus, assertValidWithCredentials } from '../asserts'; import { mergeHeaders, mergeParams } from '../utilities'; @@ -122,6 +123,15 @@ export class HttpRequestBuilder { 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); diff --git a/services/http-client/src/core/index.ts b/services/http-client/src/core/index.ts index 64f0f3e7..a61ca095 100644 --- a/services/http-client/src/core/index.ts +++ b/services/http-client/src/core/index.ts @@ -1,2 +1,2 @@ export { HttpClient } from './HttpClient'; -export { HttpRequestBuilder } from './HttpRequestBuilder'; \ No newline at end of file +export { HttpRequestBuilder } from './HttpRequestBuilder'; diff --git a/services/http-client/src/index.ts b/services/http-client/src/index.ts index 575d3c01..43663699 100644 --- a/services/http-client/src/index.ts +++ b/services/http-client/src/index.ts @@ -2,4 +2,4 @@ export { HttpClient, HttpRequestBuilder } from './core'; export { FetchAdapter, XhrAdapter } from './adapters'; export * from './constants'; export * from './errors'; -export * from './types'; \ No newline at end of file +export * from './types'; diff --git a/services/http-client/src/types/HttpClientOptions.ts b/services/http-client/src/types/HttpClientOptions.ts index 0cf83783..ec4aeab4 100644 --- a/services/http-client/src/types/HttpClientOptions.ts +++ b/services/http-client/src/types/HttpClientOptions.ts @@ -2,6 +2,7 @@ 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. */ @@ -10,6 +11,8 @@ export interface IHttpClientOptions { 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; diff --git a/services/http-client/src/types/HttpHeaders.ts b/services/http-client/src/types/HttpHeaders.ts index 54652098..805414b5 100644 --- a/services/http-client/src/types/HttpHeaders.ts +++ b/services/http-client/src/types/HttpHeaders.ts @@ -1 +1 @@ -export type THttpHeaders = Record; \ No newline at end of file +export type THttpHeaders = Record; diff --git a/services/http-client/src/types/HttpMethod.ts b/services/http-client/src/types/HttpMethod.ts index 6e4d6d94..0b83fbe2 100644 --- a/services/http-client/src/types/HttpMethod.ts +++ b/services/http-client/src/types/HttpMethod.ts @@ -1,3 +1,3 @@ import { HTTP_METHODS } from '../constants'; -export type THttpMethod = typeof HTTP_METHODS[keyof typeof HTTP_METHODS]; \ No newline at end of file +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 index 4f23b4a2..a8240c6d 100644 --- a/services/http-client/src/types/HttpParams.ts +++ b/services/http-client/src/types/HttpParams.ts @@ -1,10 +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 THttpParamValue = THttpParamPrimitive | null | undefined | Array; export type THttpParams = Record; diff --git a/services/http-client/src/types/HttpRequestConfig.ts b/services/http-client/src/types/HttpRequestConfig.ts index 9bd742ce..58d43d25 100644 --- a/services/http-client/src/types/HttpRequestConfig.ts +++ b/services/http-client/src/types/HttpRequestConfig.ts @@ -2,6 +2,7 @@ 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; @@ -12,6 +13,7 @@ export interface IHttpRequestConfig { 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/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/index.ts b/services/http-client/src/types/index.ts index d2de229f..0a1daea3 100644 --- a/services/http-client/src/types/index.ts +++ b/services/http-client/src/types/index.ts @@ -13,3 +13,4 @@ export * from './HttpParams'; export * from './HttpResponseType'; export * from './RequestBuilderErrorCode'; export * from './XhrAdapterOptions'; +export * from './ValidateStatus'; diff --git a/services/http-client/src/utilities/index.ts b/services/http-client/src/utilities/index.ts index 87fff5ab..66635d9c 100644 --- a/services/http-client/src/utilities/index.ts +++ b/services/http-client/src/utilities/index.ts @@ -3,3 +3,4 @@ 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/test/__fixtures__/constants.ts b/services/http-client/test/__fixtures__/constants.ts index b18bbdba..8a4f5db5 100644 --- a/services/http-client/test/__fixtures__/constants.ts +++ b/services/http-client/test/__fixtures__/constants.ts @@ -1 +1 @@ -export const BASE_URL = 'https://api.test.com'; \ No newline at end of file +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 index e94e4b16..c94f80f8 100644 --- a/services/http-client/test/__fixtures__/index.ts +++ b/services/http-client/test/__fixtures__/index.ts @@ -1 +1 @@ -export * from './constants'; \ No newline at end of file +export * from './constants'; diff --git a/services/http-client/test/core/HttpClient.constructor.tests.ts b/services/http-client/test/core/HttpClient.constructor.tests.ts index af5ee827..d84af342 100644 --- a/services/http-client/test/core/HttpClient.constructor.tests.ts +++ b/services/http-client/test/core/HttpClient.constructor.tests.ts @@ -55,6 +55,11 @@ describe('HttpClient constructor', () => { 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' }, @@ -91,6 +96,7 @@ describe('HttpClient constructor', () => { headers: { 'X-Default': 'value' }, params: { locale: 'ru' }, timeout: 1000, + validateStatus: status => status >= 200 && status < 400, withCredentials: true, onRequest: config => config, onRequestError: () => undefined, diff --git a/services/http-client/test/core/HttpClient.defaultConfig.tests.ts b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts index 25ac0e93..66f398f4 100644 --- a/services/http-client/test/core/HttpClient.defaultConfig.tests.ts +++ b/services/http-client/test/core/HttpClient.defaultConfig.tests.ts @@ -159,6 +159,50 @@ describe.each(adapters)('HttpClient.$name — default config', ({ create }) => { } }); + 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(); diff --git a/services/http-client/test/core/HttpClient.hooks.tests.ts b/services/http-client/test/core/HttpClient.hooks.tests.ts index 6ad7d41c..c603184e 100644 --- a/services/http-client/test/core/HttpClient.hooks.tests.ts +++ b/services/http-client/test/core/HttpClient.hooks.tests.ts @@ -106,6 +106,16 @@ describe.each(adapters)('HttpClient.$name — hooks', ({ create }) => { 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( diff --git a/services/http-client/test/core/HttpClient.withAdapter.tests.ts b/services/http-client/test/core/HttpClient.withAdapter.tests.ts index bb6a060b..4186a18f 100644 --- a/services/http-client/test/core/HttpClient.withAdapter.tests.ts +++ b/services/http-client/test/core/HttpClient.withAdapter.tests.ts @@ -35,12 +35,14 @@ 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 }); @@ -58,6 +60,7 @@ describe('HttpClient.withAdapter', () => { headers: { 'X-Default': 'default' }, params: { locale: 'ru' }, timeout: 1000, + validateStatus, withCredentials: true }) ]); diff --git a/services/http-client/test/core/HttpRequestBuilder.tests-d.ts b/services/http-client/test/core/HttpRequestBuilder.tests-d.ts index 7ae2df82..fccb399c 100644 --- a/services/http-client/test/core/HttpRequestBuilder.tests-d.ts +++ b/services/http-client/test/core/HttpRequestBuilder.tests-d.ts @@ -1,7 +1,7 @@ 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 } from '../../src/types'; +import { IHttpRequestConfig, IHttpResponse, THttpParams, THttpRequestExecutor, TValidateStatus } from '../../src/types'; const executor: THttpRequestExecutor = (config: IHttpRequestConfig): Promise> => Promise.resolve({ @@ -44,6 +44,9 @@ describe('HttpRequestBuilder types', () => { .timeout(1000); expectTypeOf(builder.execute()).toEqualTypeOf>>(); + expectTypeOf(builder.validateStatus(status => status === 201).execute()).toEqualTypeOf< + Promise> + >(); expectTypeOf(builder.asBlob().execute()).toEqualTypeOf>>(); }); @@ -56,6 +59,13 @@ describe('HttpRequestBuilder types', () => { 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', () => { diff --git a/services/http-client/test/core/HttpRequestBuilder.tests.ts b/services/http-client/test/core/HttpRequestBuilder.tests.ts index b75d9051..b00b70fe 100644 --- a/services/http-client/test/core/HttpRequestBuilder.tests.ts +++ b/services/http-client/test/core/HttpRequestBuilder.tests.ts @@ -38,6 +38,7 @@ 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') @@ -48,6 +49,7 @@ describe('HttpRequestBuilder', () => { .body({ name: 'Item' }) .signal(controller.signal) .timeout(0) + .validateStatus(validateStatus) .withCredentials(true) .bearer('token') .asJson(); @@ -67,6 +69,7 @@ describe('HttpRequestBuilder', () => { data: { name: 'Item' }, signal: controller.signal, timeout: 0, + validateStatus, withCredentials: true, responseType: HTTP_RESPONSE_TYPES.JSON }); @@ -215,6 +218,12 @@ describe('HttpRequestBuilder', () => { 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(' '), 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); + }); +});