From 6662224a9ad68472f977057af6aa0e2565b03e6b Mon Sep 17 00:00:00 2001 From: "S.Gromov" Date: Tue, 30 Jun 2026 23:52:06 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=B5=D1=80=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0=D1=82=D1=8C=20=D0=BA=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=BC=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8E=20HttpClient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - добавлен плоский ApiConfig с lifecycle hooks - добавлены ApiError, retry context, timeout и кастомные parser/serializer - обновлены примеры, документация и тесты под новый API --- AGENTS.md | 19 +- README.md | 185 ++++++- example.ts | 24 +- src/generator.ts | 4 +- src/templates/http-client.ejs | 550 +++++++++++++++------ tests/README.md | 19 +- tests/integration/e2e-generation.test.ts | 116 ++++- tests/integration/generated-client.test.ts | 10 +- tests/unit/generator.test.ts | 9 +- 9 files changed, 719 insertions(+), 217 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 041ca3f..b603207 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,8 +100,23 @@ import { createApiClient, HttpClient } from './generated'; import { getProfile } from './generated/operations/get-profile'; import { login } from './generated/operations/login'; -const httpClient = new HttpClient(); -httpClient.setSecurityData({ token: 'jwt-token' }); +const token = 'jwt-token'; + +const httpClient = new HttpClient({ + onRequest: (params) => { + if (!params.secure) { + return params; + } + + return { + ...params, + headers: { + ...params.headers, + Authorization: `Bearer ${token}`, + }, + }; + }, +}); const api = createApiClient(httpClient, { auth: { diff --git a/README.md b/README.md index 59c36ab..8bc2493 100644 --- a/README.md +++ b/README.md @@ -137,36 +137,189 @@ const pharmacies = await v1AdminPharmaciesList(http, {}); ## Кастомный HTTP Клиент -`HttpClient` создается отдельно и передается в generated operations. Это позволяет централизованно настраивать авторизацию, headers, baseUrl и transport behavior. +`HttpClient` создается отдельно и передается в generated operations. Это позволяет централизованно настраивать `baseUrl`, headers, авторизацию, timeout и transport behavior. ```typescript -import { HttpClient } from './generated'; +import { ApiError, HttpClient } from './generated'; -type SecurityData = { - token: string; -}; - -const http = new HttpClient({ +const http = new HttpClient({ baseUrl: 'https://api.example.com', - baseApiParams: { - headers: { - 'X-App-Version': '1.0.0', - }, + credentials: 'include', + timeout: 10000, + headers: { + 'X-App-Version': '1.0.0', }, - securityWorker: (securityData) => { - if (!securityData?.token) { - return undefined; + onRequest: (params) => { + const token = localStorage.getItem('access_token'); + + if (!params.secure || !token) { + return params; } return { + ...params, headers: { - Authorization: `Bearer ${securityData.token}`, + ...params.headers, + Authorization: `Bearer ${token}`, + }, + }; + }, + onResponse: (response) => response, + onError: async (error, context) => { + if (error instanceof ApiError && error.status === 401 && context.retryCount === 0) { + await refreshToken(); + return context.retry(); + } + + throw error; + }, +}); +``` + +`onRequest` вызывается перед `fetch`, `onResponse` после успешного ответа, `onError` для HTTP-ошибок, network errors и ошибок парсинга. Для защищенных endpoints generated operation передает `secure: true`, поэтому авторизацию можно добавлять только там, где она нужна. + +### Опции HttpClient + +`HttpClient` принимает плоский конфиг. Стандартные `fetch`-опции можно задавать прямо в конструкторе вместе с кастомными hooks клиента. + +| Опция | Тип | Назначение | +| --- | --- | --- | +| `baseUrl` | `string` | Базовый URL API. По умолчанию берется из `servers[0].url` OpenAPI спецификации. | +| `headers` | `HeadersInit` | Заголовки по умолчанию для всех запросов. | +| `credentials` | `RequestCredentials` | Политика отправки cookies/auth credentials: `omit`, `same-origin`, `include`. | +| `mode` | `RequestMode` | Fetch request mode, например `cors`, `same-origin`, `no-cors`. | +| `cache` | `RequestCache` | Fetch cache policy. | +| `redirect` | `RequestRedirect` | Поведение при redirect: `follow`, `error`, `manual`. | +| `referrer` | `string` | Значение referrer для запросов. | +| `referrerPolicy` | `ReferrerPolicy` | Политика referrer. | +| `integrity` | `string` | Subresource integrity value. | +| `keepalive` | `boolean` | Позволяет запросу пережить unload страницы. | +| `secure` | `boolean` | Дефолтный маркер защищенного запроса. Обычно выставляется generated operation по OpenAPI `security`. | +| `type` | `ContentType` | Дефолтный `Content-Type` для body. Обычно выставляется generated operation. | +| `format` | `ResponseFormat` | Дефолтный способ парсинга ответа: `json`, `text`, `blob`, `formData`, `arrayBuffer`. | +| `timeout` | `number` | Таймаут запроса в миллисекундах. Работает через `AbortSignal`. | +| `customFetch` | `typeof fetch` | Замена стандартного `fetch`, например для тестов, SSR или custom transport. | +| `paramsSerializer` | `(query) => string` | Кастомная сериализация query params в URL. | +| `responseParser` | `(response, format) => unknown` | Кастомный парсинг response body. | +| `onRequest` | `(params, context) => params` | Request interceptor перед вызовом `fetch`. | +| `onResponse` | `(response, context) => response` | Response interceptor после успешного HTTP ответа. | +| `onError` | `(error, context) => result` | Error interceptor для HTTP ошибок, network errors, retry и refresh-token сценариев. | + +Также можно использовать другие стандартные поля `RequestInit`, доступные в вашей TypeScript/Runtime среде, если они не относятся к конкретному request body или cancellation lifecycle. + +Эти поля не задаются на уровне конструктора и передаются конкретной operation или самому `request`: + +| Поле | Почему не в конструкторе | +| --- | --- | +| `path` | Генерируется из OpenAPI path для конкретной operation. | +| `method` | Генерируется из OpenAPI HTTP метода. | +| `query` | Уникален для конкретного вызова operation. | +| `body` | Уникален для конкретного запроса. | +| `signal` | Один общий `AbortSignal` может случайно отменить группу независимых запросов. | +| `cancelToken` | Всегда относится к одному конкретному запросу. | + +### Рецепты Кастомизации + +Авторизация через `onRequest`: + +```typescript +const http = new HttpClient({ + onRequest: (params) => { + const token = localStorage.getItem('access_token'); + + if (!params.secure || !token) { + return params; + } + + return { + ...params, + headers: { + ...params.headers, + Authorization: `Bearer ${token}`, }, }; }, }); +``` -http.setSecurityData({ token: 'jwt-token' }); +Refresh token и повтор запроса: + +```typescript +const http = new HttpClient({ + onError: async (error, context) => { + if (error instanceof ApiError && error.status === 401 && context.retryCount === 0) { + await refreshToken(); + return context.retry(); + } + + throw error; + }, +}); +``` + +Логирование ответов через `onResponse`: + +```typescript +const http = new HttpClient({ + onResponse: (response, context) => { + console.log(context.request.method, context.url, response.status); + return response; + }, +}); +``` + +Кастомная сериализация query params: + +```typescript +const http = new HttpClient({ + paramsSerializer: (query) => { + const params = new URLSearchParams(); + + Object.entries(query).forEach(([key, value]) => { + if (Array.isArray(value)) { + params.set(key, value.join(',')); + return; + } + + if (value !== undefined) { + params.set(key, String(value)); + } + }); + + return params.toString(); + }, +}); +``` + +Замена транспорта через `customFetch`: + +```typescript +const http = new HttpClient({ + customFetch: async (url, init) => { + console.log('request', url); + return fetch(url, init); + }, +}); +``` + +Кастомный парсинг ответа: + +```typescript +const http = new HttpClient({ + responseParser: async (response) => { + if (response.status === 204) { + return undefined; + } + + const contentType = response.headers.get('content-type') || ''; + + if (contentType.includes('json')) { + return response.json(); + } + + return response.text(); + }, +}); ``` Для отдельного вызова можно передать дополнительные request params последним аргументом operation. diff --git a/example.ts b/example.ts index 5fcb250..290b165 100644 --- a/example.ts +++ b/example.ts @@ -8,25 +8,23 @@ import { getProfile } from './output/operations/get-profile'; import { login } from './output/operations/login'; import { register } from './output/operations/register'; -type SecurityData = { - token: string; -}; +let accessToken: string | null = null; -const httpClient = new HttpClient({ +const httpClient = new HttpClient({ baseUrl: 'https://api.example.com', - baseApiParams: { - headers: { - 'Content-Type': 'application/json', - }, + headers: { + 'Content-Type': 'application/json', }, - securityWorker: (securityData) => { - if (!securityData?.token) { - return undefined; + onRequest: (params) => { + if (!params.secure || !accessToken) { + return params; } return { + ...params, headers: { - Authorization: `Bearer ${securityData.token}`, + ...params.headers, + Authorization: `Bearer ${accessToken}`, }, }; }, @@ -58,7 +56,7 @@ async function loginUser() { password: 'SecurePassword123', }); - httpClient.setSecurityData({ token: result.access_token }); + accessToken = result.access_token; return result; } diff --git a/src/generator.ts b/src/generator.ts index c8af9b8..f8a6fa9 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -542,8 +542,8 @@ export async function generate(config: GeneratorConfig): Promise { [ 'export { createApiClient } from "./create-api-client";', 'export type { ApiOperation, ApiTree, BoundApi } from "./create-api-client";', - 'export { ContentType, HttpClient } from "./http-client";', - 'export type { ApiConfig, ApiRequestClient, FullRequestParams, HttpResponse, QueryParamsType, RequestParams, ResponseFormat } from "./http-client";', + 'export { ApiError, ContentType, HttpClient } from "./http-client";', + 'export type { ApiConfig, ApiRequestClient, ErrorInterceptor, FullRequestParams, HttpResponse, ParamsSerializer, QueryParamsType, RequestContext, RequestInterceptor, RequestParams, ResponseFormat, ResponseInterceptor, ResponseParser } from "./http-client";', 'export type * from "./data-contracts";', 'export { operationsTree } from "./operations-tree";', 'export type { OperationsTree } from "./operations-tree";', diff --git a/src/templates/http-client.ejs b/src/templates/http-client.ejs index 8a2a653..17a88a6 100644 --- a/src/templates/http-client.ejs +++ b/src/templates/http-client.ejs @@ -1,5 +1,5 @@ <% -const { apiConfig, generateResponses, config } = it; +const { apiConfig, config } = it; const baseUrl = apiConfig?.baseUrl || ""; %> @@ -7,7 +7,7 @@ export type QueryParamsType = Record; export type ResponseFormat = keyof Omit; export interface FullRequestParams extends Omit { - /** set parameter to `true` for call `securityWorker` for this request */ + /** set parameter to `true` to mark this request as protected */ secure?: boolean; /** request path */ path: string; @@ -23,151 +23,395 @@ export interface FullRequestParams extends Omit { baseUrl?: string; /** request cancellation token */ cancelToken?: CancelToken; + /** request timeout in milliseconds */ + timeout?: number; } -export type RequestParams = Omit +export type RequestParams = Omit; + +export interface RequestContext { + url: string; + request: FullRequestParams; + retryCount: number; + retry: () => Promise; +} + +export type RequestInterceptor = ( + params: FullRequestParams, + context: RequestContext, +) => FullRequestParams | Promise; + +export type ResponseInterceptor = ( + response: HttpResponse, + context: RequestContext, +) => HttpResponse | Promise>; + +export type ErrorInterceptor = ( + error: unknown, + context: RequestContext, +) => TResult | Promise; + +export type ParamsSerializer = (query: QueryParamsType) => string; +export type ResponseParser = (response: Response, format?: ResponseFormat) => unknown | Promise; export interface ApiRequestClient { - request(params: FullRequestParams): Promise; +<% if (config.unwrapResponseData) { %> + request(params: FullRequestParams): Promise; +<% } else { %> + request(params: FullRequestParams): Promise>; +<% } %> } -export interface ApiConfig { - baseUrl?: string; - baseApiParams?: Omit; - securityWorker?: (securityData: SecurityDataType | null) => Promise | RequestParams | void; - customFetch?: typeof fetch; +export interface ApiConfig extends Omit { + baseUrl?: string; + customFetch?: typeof fetch; + paramsSerializer?: ParamsSerializer; + responseParser?: ResponseParser; + onRequest?: RequestInterceptor; + onResponse?: ResponseInterceptor; + onError?: ErrorInterceptor; } export interface HttpResponse extends Response { - data: D; - error: E; + data: D; + error: E; +} + +export class ApiError extends Error { + public readonly status: number; + public readonly statusText: string; + public readonly response: Response; + public readonly data: unknown; + public readonly error: E; + public readonly request: FullRequestParams; + + constructor(response: Response, request: FullRequestParams, data: unknown, error: E) { + super(`Request failed with status ${response.status} ${response.statusText}`.trim()); + this.name = "ApiError"; + this.status = response.status; + this.statusText = response.statusText; + this.response = response; + this.data = data; + this.error = error; + this.request = request; + } } export type CancelToken = Symbol | string | number; export enum ContentType { - Json = "application/json", - JsonApi = "application/vnd.api+json", - FormData = "multipart/form-data", - UrlEncoded = "application/x-www-form-urlencoded", - Text = "text/plain", + Json = "application/json", + JsonApi = "application/vnd.api+json", + FormData = "multipart/form-data", + UrlEncoded = "application/x-www-form-urlencoded", + Text = "text/plain", } export class HttpClient implements ApiRequestClient { - public baseUrl: string = "<%~ baseUrl %>"; - private securityData: SecurityDataType | null = null; - private securityWorker?: ApiConfig["securityWorker"]; - private abortControllers = new Map(); - private customFetch = (...fetchParams: Parameters) => fetch(...fetchParams); + public baseUrl: string = "<%~ baseUrl %>"; + private abortControllers = new Map(); + private customFetch: typeof fetch = (...fetchParams) => fetch(...fetchParams); + private paramsSerializer?: ParamsSerializer; + private responseParser?: ResponseParser; + private onRequest?: RequestInterceptor; + private onResponse?: ResponseInterceptor; + private onError?: ErrorInterceptor; - private baseApiParams: RequestParams = { - credentials: 'same-origin', - headers: {}, - redirect: 'follow', - referrerPolicy: 'no-referrer', + private baseRequestParams: RequestParams = { + credentials: "same-origin", + headers: {}, + redirect: "follow", + referrerPolicy: "no-referrer", + }; + + constructor({ + baseUrl, + customFetch, + paramsSerializer, + responseParser, + onRequest, + onResponse, + onError, + ...baseRequestParams + }: ApiConfig = {}) { + if (typeof baseUrl === "string") { + this.baseUrl = baseUrl; } - constructor(apiConfig: ApiConfig = {}) { - Object.assign(this, apiConfig); + this.customFetch = customFetch || this.customFetch; + this.paramsSerializer = paramsSerializer; + this.responseParser = responseParser; + this.onRequest = onRequest; + this.onResponse = onResponse; + this.onError = onError; + this.baseRequestParams = this.mergeRequestParams(this.baseRequestParams, baseRequestParams); + } + + protected encodeQueryParam(key: string, value: any) { + const encodedKey = encodeURIComponent(key); + return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`; + } + + protected addQueryParam(query: QueryParamsType, key: string) { + return this.encodeQueryParam(key, query[key]); + } + + protected addArrayQueryParam(query: QueryParamsType, key: string) { + const value = query[key]; + return value.map((v: any) => this.encodeQueryParam(key, v)).join("&"); + } + + protected toQueryString(rawQuery?: QueryParamsType): string { + const query = rawQuery || {}; + + if (this.paramsSerializer) { + return this.paramsSerializer(query); } - public setSecurityData = (data: SecurityDataType | null) => { - this.securityData = data; - } + const keys = Object.keys(query).filter((key) => "undefined" !== typeof query[key]); + return keys + .map((key) => + Array.isArray(query[key]) + ? this.addArrayQueryParam(query, key) + : this.addQueryParam(query, key), + ) + .join("&"); + } - protected encodeQueryParam(key: string, value: any) { - const encodedKey = encodeURIComponent(key); - return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`; - } + protected addQueryParams(rawQuery?: QueryParamsType): string { + const queryString = this.toQueryString(rawQuery); + return queryString ? `?${queryString}` : ""; + } - protected addQueryParam(query: QueryParamsType, key: string) { - return this.encodeQueryParam(key, query[key]); - } + protected buildRequestUrl(baseUrl: string | undefined, path: string, query?: QueryParamsType): string { + return `${baseUrl || this.baseUrl || ""}${path}${this.addQueryParams(query)}`; + } - protected addArrayQueryParam(query: QueryParamsType, key: string) { - const value = query[key]; - return value.map((v: any) => this.encodeQueryParam(key, v)).join("&"); - } + protected createRequestContext( + request: FullRequestParams, + retryCount: number, + retry: () => Promise, + ): RequestContext { + return { + url: this.buildRequestUrl(request.baseUrl, request.path, request.query), + request, + retryCount, + retry, + }; + } - protected toQueryString(rawQuery?: QueryParamsType): string { - const query = rawQuery || {}; - const keys = Object.keys(query).filter((key) => "undefined" !== typeof query[key]); - return keys - .map((key) => - Array.isArray(query[key]) - ? this.addArrayQueryParam(query, key) - : this.addQueryParam(query, key), - ) - .join("&"); - } + protected updateRequestContext(context: RequestContext, request: FullRequestParams) { + context.request = request; + context.url = this.buildRequestUrl(request.baseUrl, request.path, request.query); + } - protected addQueryParams(rawQuery?: QueryParamsType): string { - const queryString = this.toQueryString(rawQuery); - return queryString ? `?${queryString}` : ""; - } + protected mergeHeaders(...headers: Array): HeadersInit { + const mergedHeaders = new Headers(); - private contentFormatters: Record any> = { - [ContentType.Json]: (input:any) => input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input, - [ContentType.JsonApi]: (input:any) => input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input, - [ContentType.Text]: (input:any) => input !== null && typeof input !== "string" ? JSON.stringify(input) : input, - [ContentType.FormData]: (input: any) => { - if (input instanceof FormData) { - return input; - } + headers.forEach((headers) => { + if (!headers) { + return; + } - return Object.keys(input || {}).reduce((formData, key) => { - const property = input[key]; - formData.append( - key, - property instanceof Blob ? - property : - typeof property === "object" && property !== null ? - JSON.stringify(property) : - `${property}` - ); - return formData; - }, new FormData()); - }, - [ContentType.UrlEncoded]: (input: any) => this.toQueryString(input), - } + new Headers(headers).forEach((value, key) => mergedHeaders.set(key, value)); + }); - protected mergeRequestParams(params1: RequestParams, params2?: RequestParams): RequestParams { - return { - ...this.baseApiParams, - ...params1, - ...(params2 || {}), - headers: { - ...(this.baseApiParams.headers || {}), - ...(params1.headers || {}), - ...((params2 && params2.headers) || {}), - }, - }; - } + return Object.fromEntries(mergedHeaders.entries()); + } - protected createAbortSignal = (cancelToken: CancelToken): AbortSignal | undefined => { - if (this.abortControllers.has(cancelToken)) { - const abortController = this.abortControllers.get(cancelToken); - if (abortController) { - return abortController.signal; - } - return void 0; - } + protected mergeRequestParams>( + params1: T, + params2?: Partial, + ): T { + return { + ...params1, + ...(params2 || {}), + headers: this.mergeHeaders(params1.headers, params2?.headers), + } as T; + } - const abortController = new AbortController(); - this.abortControllers.set(cancelToken, abortController); + protected createAbortSignal = (cancelToken: CancelToken): AbortSignal | undefined => { + if (this.abortControllers.has(cancelToken)) { + const abortController = this.abortControllers.get(cancelToken); + if (abortController) { return abortController.signal; + } + return void 0; } - public abortRequest = (cancelToken: CancelToken) => { - const abortController = this.abortControllers.get(cancelToken) + const abortController = new AbortController(); + this.abortControllers.set(cancelToken, abortController); + return abortController.signal; + }; - if (abortController) { - abortController.abort(); - this.abortControllers.delete(cancelToken); + protected createRequestSignal = ( + signal?: AbortSignal | null, + cancelToken?: CancelToken, + timeout?: number, + ): { signal: AbortSignal | null; cleanup: () => void } => { + const signals: AbortSignal[] = []; + let timeoutId: ReturnType | undefined; + + if (signal) { + signals.push(signal); + } + + if (cancelToken) { + const cancelSignal = this.createAbortSignal(cancelToken); + if (cancelSignal) { + signals.push(cancelSignal); + } + } + + if (typeof timeout === "number" && timeout > 0) { + const timeoutController = new AbortController(); + timeoutId = setTimeout(() => timeoutController.abort(), timeout); + signals.push(timeoutController.signal); + } + + const cleanupTimeout = () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + + if (signals.length === 0) { + return { signal: null, cleanup: cleanupTimeout }; + } + + if (signals.length === 1) { + return { signal: signals[0] || null, cleanup: cleanupTimeout }; + } + + const abortController = new AbortController(); + const abortRequest = () => abortController.abort(); + + signals.forEach((signal) => { + if (signal.aborted) { + abortController.abort(); + } else { + signal.addEventListener("abort", abortRequest, { once: true }); + } + }); + + return { + signal: abortController.signal, + cleanup: () => { + cleanupTimeout(); + signals.forEach((signal) => signal.removeEventListener("abort", abortRequest)); + }, + }; + }; + + public abortRequest = (cancelToken: CancelToken) => { + const abortController = this.abortControllers.get(cancelToken); + + if (abortController) { + abortController.abort(); + this.abortControllers.delete(cancelToken); + } + }; + + private contentFormatters: Record any> = { + [ContentType.Json]: (input: any) => input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input, + [ContentType.JsonApi]: (input: any) => input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input, + [ContentType.Text]: (input: any) => input !== null && typeof input !== "string" ? JSON.stringify(input) : input, + [ContentType.FormData]: (input: any) => { + if (input instanceof FormData) { + return input; + } + + return Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + formData.append( + key, + property instanceof Blob + ? property + : typeof property === "object" && property !== null + ? JSON.stringify(property) + : `${property}`, + ); + return formData; + }, new FormData()); + }, + [ContentType.UrlEncoded]: (input: any) => this.toQueryString(input), + }; + + protected parseResponse = async (response: Response, responseFormat?: ResponseFormat): Promise> => { + const parsedResponse = response as HttpResponse; + parsedResponse.data = (null as unknown) as T; + parsedResponse.error = (null as unknown) as E; + + if (!responseFormat && !this.responseParser) { + return parsedResponse; + } + + const responseToParse = response.clone(); + + await Promise.resolve( + this.responseParser + ? this.responseParser(responseToParse, responseFormat) + : responseToParse[responseFormat as ResponseFormat](), + ) + .then((data) => { + if (parsedResponse.ok) { + parsedResponse.data = data as T; + } else { + parsedResponse.error = data as E; } - } + }) + .catch((error) => { + parsedResponse.error = error as E; + }); - public request = async ({ + return parsedResponse; + }; + + public request = async (requestParams: FullRequestParams) => { + return this.requestWithRetry(requestParams, 0); + }; + + private requestWithRetry = async ( + requestParams: FullRequestParams, + retryCount: number, +<% if (config.unwrapResponseData) { %> + ): Promise => { +<% } else { %> + ): Promise> => { +<% } %> + let request = this.mergeRequestParams(this.baseRequestParams, requestParams) as FullRequestParams; + request.baseUrl = request.baseUrl || this.baseUrl; + request.secure = typeof request.secure === "boolean" ? request.secure : this.baseRequestParams.secure; + + const context = this.createRequestContext< +<% if (config.unwrapResponseData) { %> + T +<% } else { %> + HttpResponse +<% } %> + >( + request, + retryCount, + () => this.requestWithRetry(requestParams, retryCount + 1), + ); + + let cleanupSignal = () => {}; + let cancelToken: CancelToken | undefined; + + const cleanupRequest = () => { + cleanupSignal(); + if (cancelToken) { + this.abortControllers.delete(cancelToken); + } + }; + + try { + if (this.onRequest) { + request = await this.onRequest(request, context); + this.updateRequestContext(context, request); + } + + const { body, secure, path, @@ -175,62 +419,54 @@ export class HttpClient implements ApiRequestClient query, format, baseUrl, - cancelToken, + cancelToken: requestCancelToken, + timeout, ...params -<% if (config.unwrapResponseData) { %> - }: FullRequestParams): Promise => { -<% } else { %> - }: FullRequestParams): Promise> => { -<% } %> - const secureParams = ((typeof secure === 'boolean' ? secure : this.baseApiParams.secure) && this.securityWorker && await this.securityWorker(this.securityData)) || {}; - const requestParams = this.mergeRequestParams(params, secureParams); - const queryString = query && this.toQueryString(query); - const payloadFormatter = this.contentFormatters[type || ContentType.Json]; - const responseFormat = format || requestParams.format; + } = request; - return this.customFetch( - `${baseUrl || this.baseUrl || ""}${path}${queryString ? `?${queryString}` : ""}`, - { - ...requestParams, - headers: { - ...(requestParams.headers || {}), - ...(type && type !== ContentType.FormData ? { "Content-Type": type } : {}), - }, - signal: (cancelToken ? this.createAbortSignal(cancelToken) : requestParams.signal) || null, - body: typeof body === "undefined" || body === null ? null : payloadFormatter(body), - } - ).then(async (response) => { - const r = response as HttpResponse; - r.data = (null as unknown) as T; - r.error = (null as unknown) as E; + cancelToken = requestCancelToken; + const { signal, cleanup } = this.createRequestSignal(params.signal, cancelToken, timeout); + cleanupSignal = cleanup; - const responseToParse = responseFormat ? response.clone() : response; - const data = !responseFormat ? r : await responseToParse[responseFormat]() - .then((data) => { - if (r.ok) { - r.data = data; - } else { - r.error = data; - } - return r; - }) - .catch((e) => { - r.error = e; - return r; - }); + const payloadFormatter = this.contentFormatters[type || ContentType.Json]; + const responseFormat = format; + const response = await this.customFetch(context.url, { + ...params, + headers: this.mergeHeaders( + params.headers, + type && type !== ContentType.FormData ? { "Content-Type": type } : undefined, + ), + signal, + body: typeof body === "undefined" || body === null ? null : payloadFormatter(body), + }); - if (cancelToken) { - this.abortControllers.delete(cancelToken); - } + const parsedResponse = await this.parseResponse(response, responseFormat); <% if (!config.disableThrowOnError) { %> - if (!response.ok) throw data; + if (!parsedResponse.ok) { + throw new ApiError(parsedResponse, request, parsedResponse.error || parsedResponse.data, parsedResponse.error); + } <% } %> + + const finalResponse = this.onResponse + ? await this.onResponse(parsedResponse, context) + : parsedResponse; + <% if (config.unwrapResponseData) { %> - return data.data; + return finalResponse.data; <% } else { %> - return data; + return finalResponse; <% } %> - }); - }; + } catch (error) { + cleanupRequest(); + + if (this.onError) { + return this.onError(error, context); + } + + throw error; + } finally { + cleanupRequest(); + } + }; } diff --git a/tests/README.md b/tests/README.md index 9ca46d9..3349b26 100644 --- a/tests/README.md +++ b/tests/README.md @@ -141,7 +141,7 @@ bun test -t "should generate client with custom name" - ❌ Без outputPath - ✅ Опциональное поле fileName -### 4. Сгенерированный клиент (7 кейсов) +### 4. Сгенерированный клиент **Компиляция:** - ✅ TypeScript компиляция без ошибок @@ -152,9 +152,10 @@ bun test -t "should generate client with custom name" - ✅ Все endpoints присутствуют - ✅ Корректные имена методов - ✅ HttpClient инициализация -- ✅ Метод `setSecurityData` +- ✅ Lifecycle hooks в `HttpClient` +- ✅ Отсутствие устаревшего `baseApiParams` -### 5. Интеграционные E2E (15 кейсов) +### 5. Интеграционные E2E **Полный цикл:** - ✅ CLI → создание → импорт → использование @@ -164,17 +165,15 @@ bun test -t "should generate client with custom name" **HTTP с mock:** - ✅ GET без параметров -- ✅ GET с query параметрами - ✅ POST с body -- ✅ PUT/PATCH/DELETE -- ✅ Статусы 200, 201, 400, 401, 404, 500 -- ❌ Network errors -- ✅ Bearer authentication -- ✅ Custom headers +- ✅ Обработка 404 статуса +- ✅ Bearer authentication через `onRequest` +- ✅ Retry через `onError` и `ApiError` +- ✅ Timeout через `AbortSignal` ## Дополнительная информация - [План тестирования](../TESTING-PLAN.md) - [Contributing Guidelines](../CONTRIBUTING.md) -- [Bun Test Documentation](https://bun.sh/docs/cli/test) \ No newline at end of file +- [Bun Test Documentation](https://bun.sh/docs/cli/test) diff --git a/tests/integration/e2e-generation.test.ts b/tests/integration/e2e-generation.test.ts index 53a4a63..928c686 100644 --- a/tests/integration/e2e-generation.test.ts +++ b/tests/integration/e2e-generation.test.ts @@ -339,17 +339,23 @@ describe('E2E Generation', () => { const { login } = await import(join(outputPath, 'operations', 'login.ts')); const { get } = await import(join(outputPath, 'operations', 'get.ts')); - // Создаем HttpClient с securityWorker для добавления Bearer токена + let token: string | null = null; + + // Создаем HttpClient с onRequest для добавления Bearer токена const httpClient = new HttpClient({ - securityWorker: (securityData: string | null) => { - if (securityData) { - return { - headers: { - Authorization: `Bearer ${securityData}` - } - }; + onRequest: (params) => { + if (!params.secure || !token) { + return params; } - } + + return { + ...params, + headers: { + ...params.headers, + Authorization: `Bearer ${token}`, + }, + }; + }, }); const api = createApiClient(httpClient, { @@ -368,12 +374,100 @@ describe('E2E Generation', () => { }); // Установка токена - httpClient.setSecurityData(loginResult.token); + token = loginResult.token; // Запрос с токеном const profile = await api.profile.get(); - + expect(profile.email).toBe('test@example.com'); }, 60000); + + test('onError должен поддерживать retry после ApiError', async () => { + const outputPath = join(tempDir, 'output'); + + await execa('bun', [ + 'run', + CLI_PATH, + '--input', + FIXTURES.MINIMAL, + '--output', + outputPath, + '--mode', + 'split', + ]); + + const generatedFile = join(outputPath, 'index.ts'); + const { ApiError, HttpClient } = await import(generatedFile); + let calls = 0; + + const http = new HttpClient({ + customFetch: async () => { + calls += 1; + + if (calls === 1) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + return Response.json({ ok: true }); + }, + onError: (error, context) => { + if (error instanceof ApiError && error.status === 401 && context.retryCount === 0) { + return context.retry(); + } + + throw error; + }, + }); + + const result = await http.request({ + path: '/retry', + method: 'GET', + format: 'json', + }); + + expect(calls).toBe(2); + expect(result).toEqual({ ok: true }); + }, 60000); + + test('timeout должен отменять зависший запрос', async () => { + const outputPath = join(tempDir, 'output'); + + await execa('bun', [ + 'run', + CLI_PATH, + '--input', + FIXTURES.MINIMAL, + '--output', + outputPath, + '--mode', + 'split', + ]); + + const generatedFile = join(outputPath, 'index.ts'); + const { HttpClient } = await import(generatedFile); + + const http = new HttpClient({ + customFetch: (_url, init) => new Promise((resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('The operation was aborted.', 'AbortError')); + }); + }), + onError: (error) => { + if (error instanceof DOMException && error.name === 'AbortError') { + return { aborted: true }; + } + + throw error; + }, + }); + + const result = await http.request({ + path: '/timeout', + method: 'GET', + timeout: 1, + }); + + expect(result).toEqual({ aborted: true }); + }, 60000); }); }); diff --git a/tests/integration/generated-client.test.ts b/tests/integration/generated-client.test.ts index a547a3d..7fe7223 100644 --- a/tests/integration/generated-client.test.ts +++ b/tests/integration/generated-client.test.ts @@ -148,7 +148,7 @@ describe('Generated Client', () => { expect(content).toContain('https://api.example.com'); }, 30000); - test('метод setSecurityData должен работать', async () => { + test('HttpClient должен поддерживать lifecycle hooks', async () => { const outputPath = join(tempDir, 'output'); const config: GeneratorConfig = { inputPath: FIXTURES.WITH_AUTH, @@ -162,8 +162,12 @@ describe('Generated Client', () => { const generatedFile = join(outputPath, 'http-client.ts'); const content = await readTextFile(generatedFile); - // Проверяем наличие метода для установки токена - expect(content).toContain('setSecurityData'); + // Проверяем наличие hooks для кастомизации запросов + expect(content).toContain('onRequest?: RequestInterceptor'); + expect(content).toContain('onResponse?: ResponseInterceptor'); + expect(content).toContain('onError?: ErrorInterceptor'); + expect(content).not.toContain('baseApiParams'); + expect(content).not.toContain('setSecurityData'); }, 30000); }); diff --git a/tests/unit/generator.test.ts b/tests/unit/generator.test.ts index d2fe2df..da5d693 100644 --- a/tests/unit/generator.test.ts +++ b/tests/unit/generator.test.ts @@ -251,7 +251,7 @@ describe('Generator', () => { expect(content).toContain('guest'); }, 30000); - test('должен обработать Bearer authentication', async () => { + test('должен генерировать hooks для авторизации', async () => { const outputPath = join(tempDir, 'output'); const config: GeneratorConfig = { inputPath: FIXTURES.WITH_AUTH, @@ -265,8 +265,11 @@ describe('Generator', () => { const generatedFile = join(outputPath, 'http-client.ts'); const content = await readTextFile(generatedFile); - // Проверяем наличие методов для работы с токеном - expect(content).toContain('setSecurityData'); + // Проверяем наличие hooks для добавления авторизации + expect(content).toContain('onRequest?: RequestInterceptor'); + expect(content).toContain('secure?: boolean'); + expect(content).not.toContain('baseApiParams'); + expect(content).not.toContain('setSecurityData'); }, 30000); test('должен использовать baseUrl из servers', async () => {