feat: переработать кастомизацию HttpClient
- добавлен плоский ApiConfig с lifecycle hooks - добавлены ApiError, retry context, timeout и кастомные parser/serializer - обновлены примеры, документация и тесты под новый API
This commit is contained in:
19
AGENTS.md
19
AGENTS.md
@@ -100,8 +100,23 @@ import { createApiClient, HttpClient } from './generated';
|
|||||||
import { getProfile } from './generated/operations/get-profile';
|
import { getProfile } from './generated/operations/get-profile';
|
||||||
import { login } from './generated/operations/login';
|
import { login } from './generated/operations/login';
|
||||||
|
|
||||||
const httpClient = new HttpClient();
|
const token = 'jwt-token';
|
||||||
httpClient.setSecurityData({ 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, {
|
const api = createApiClient(httpClient, {
|
||||||
auth: {
|
auth: {
|
||||||
|
|||||||
185
README.md
185
README.md
@@ -137,36 +137,189 @@ const pharmacies = await v1AdminPharmaciesList(http, {});
|
|||||||
|
|
||||||
## Кастомный HTTP Клиент
|
## Кастомный HTTP Клиент
|
||||||
|
|
||||||
`HttpClient` создается отдельно и передается в generated operations. Это позволяет централизованно настраивать авторизацию, headers, baseUrl и transport behavior.
|
`HttpClient` создается отдельно и передается в generated operations. Это позволяет централизованно настраивать `baseUrl`, headers, авторизацию, timeout и transport behavior.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { HttpClient } from './generated';
|
import { ApiError, HttpClient } from './generated';
|
||||||
|
|
||||||
type SecurityData = {
|
const http = new HttpClient({
|
||||||
token: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const http = new HttpClient<SecurityData>({
|
|
||||||
baseUrl: 'https://api.example.com',
|
baseUrl: 'https://api.example.com',
|
||||||
baseApiParams: {
|
credentials: 'include',
|
||||||
headers: {
|
timeout: 10000,
|
||||||
'X-App-Version': '1.0.0',
|
headers: {
|
||||||
},
|
'X-App-Version': '1.0.0',
|
||||||
},
|
},
|
||||||
securityWorker: (securityData) => {
|
onRequest: (params) => {
|
||||||
if (!securityData?.token) {
|
const token = localStorage.getItem('access_token');
|
||||||
return undefined;
|
|
||||||
|
if (!params.secure || !token) {
|
||||||
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...params,
|
||||||
headers: {
|
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.
|
Для отдельного вызова можно передать дополнительные request params последним аргументом operation.
|
||||||
|
|||||||
24
example.ts
24
example.ts
@@ -8,25 +8,23 @@ import { getProfile } from './output/operations/get-profile';
|
|||||||
import { login } from './output/operations/login';
|
import { login } from './output/operations/login';
|
||||||
import { register } from './output/operations/register';
|
import { register } from './output/operations/register';
|
||||||
|
|
||||||
type SecurityData = {
|
let accessToken: string | null = null;
|
||||||
token: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const httpClient = new HttpClient<SecurityData>({
|
const httpClient = new HttpClient({
|
||||||
baseUrl: 'https://api.example.com',
|
baseUrl: 'https://api.example.com',
|
||||||
baseApiParams: {
|
headers: {
|
||||||
headers: {
|
'Content-Type': 'application/json',
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
securityWorker: (securityData) => {
|
onRequest: (params) => {
|
||||||
if (!securityData?.token) {
|
if (!params.secure || !accessToken) {
|
||||||
return undefined;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...params,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${securityData.token}`,
|
...params.headers,
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -58,7 +56,7 @@ async function loginUser() {
|
|||||||
password: 'SecurePassword123',
|
password: 'SecurePassword123',
|
||||||
});
|
});
|
||||||
|
|
||||||
httpClient.setSecurityData({ token: result.access_token });
|
accessToken = result.access_token;
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -542,8 +542,8 @@ export async function generate(config: GeneratorConfig): Promise<void> {
|
|||||||
[
|
[
|
||||||
'export { createApiClient } from "./create-api-client";',
|
'export { createApiClient } from "./create-api-client";',
|
||||||
'export type { ApiOperation, ApiTree, BoundApi } from "./create-api-client";',
|
'export type { ApiOperation, ApiTree, BoundApi } from "./create-api-client";',
|
||||||
'export { ContentType, HttpClient } from "./http-client";',
|
'export { ApiError, ContentType, HttpClient } from "./http-client";',
|
||||||
'export type { ApiConfig, ApiRequestClient, FullRequestParams, HttpResponse, QueryParamsType, RequestParams, ResponseFormat } 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 type * from "./data-contracts";',
|
||||||
'export { operationsTree } from "./operations-tree";',
|
'export { operationsTree } from "./operations-tree";',
|
||||||
'export type { OperationsTree } from "./operations-tree";',
|
'export type { OperationsTree } from "./operations-tree";',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<%
|
<%
|
||||||
const { apiConfig, generateResponses, config } = it;
|
const { apiConfig, config } = it;
|
||||||
const baseUrl = apiConfig?.baseUrl || "";
|
const baseUrl = apiConfig?.baseUrl || "";
|
||||||
%>
|
%>
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ export type QueryParamsType = Record<string | number, any>;
|
|||||||
export type ResponseFormat = keyof Omit<Body, "body" | "bodyUsed">;
|
export type ResponseFormat = keyof Omit<Body, "body" | "bodyUsed">;
|
||||||
|
|
||||||
export interface FullRequestParams extends Omit<RequestInit, "body"> {
|
export interface FullRequestParams extends Omit<RequestInit, "body"> {
|
||||||
/** set parameter to `true` for call `securityWorker` for this request */
|
/** set parameter to `true` to mark this request as protected */
|
||||||
secure?: boolean;
|
secure?: boolean;
|
||||||
/** request path */
|
/** request path */
|
||||||
path: string;
|
path: string;
|
||||||
@@ -23,151 +23,395 @@ export interface FullRequestParams extends Omit<RequestInit, "body"> {
|
|||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
/** request cancellation token */
|
/** request cancellation token */
|
||||||
cancelToken?: CancelToken;
|
cancelToken?: CancelToken;
|
||||||
|
/** request timeout in milliseconds */
|
||||||
|
timeout?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RequestParams = Omit<FullRequestParams, "body" | "method" | "query" | "path">
|
export type RequestParams = Omit<FullRequestParams, "body" | "method" | "query" | "path">;
|
||||||
|
|
||||||
|
export interface RequestContext<TResult = unknown> {
|
||||||
|
url: string;
|
||||||
|
request: FullRequestParams;
|
||||||
|
retryCount: number;
|
||||||
|
retry: () => Promise<TResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestInterceptor = (
|
||||||
|
params: FullRequestParams,
|
||||||
|
context: RequestContext,
|
||||||
|
) => FullRequestParams | Promise<FullRequestParams>;
|
||||||
|
|
||||||
|
export type ResponseInterceptor = <D = unknown, E = unknown>(
|
||||||
|
response: HttpResponse<D, E>,
|
||||||
|
context: RequestContext,
|
||||||
|
) => HttpResponse<D, E> | Promise<HttpResponse<D, E>>;
|
||||||
|
|
||||||
|
export type ErrorInterceptor = <TResult = unknown>(
|
||||||
|
error: unknown,
|
||||||
|
context: RequestContext<TResult>,
|
||||||
|
) => TResult | Promise<TResult>;
|
||||||
|
|
||||||
|
export type ParamsSerializer = (query: QueryParamsType) => string;
|
||||||
|
export type ResponseParser = (response: Response, format?: ResponseFormat) => unknown | Promise<unknown>;
|
||||||
|
|
||||||
export interface ApiRequestClient {
|
export interface ApiRequestClient {
|
||||||
request<T = any, E = any>(params: FullRequestParams): Promise<T>;
|
<% if (config.unwrapResponseData) { %>
|
||||||
|
request<T = any, E = any>(params: FullRequestParams): Promise<T>;
|
||||||
|
<% } else { %>
|
||||||
|
request<T = any, E = any>(params: FullRequestParams): Promise<HttpResponse<T, E>>;
|
||||||
|
<% } %>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiConfig<SecurityDataType = unknown> {
|
export interface ApiConfig<SecurityDataType = unknown> extends Omit<RequestParams, "baseUrl" | "cancelToken" | "signal"> {
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
baseApiParams?: Omit<RequestParams, "baseUrl" | "cancelToken" | "signal">;
|
customFetch?: typeof fetch;
|
||||||
securityWorker?: (securityData: SecurityDataType | null) => Promise<RequestParams | void> | RequestParams | void;
|
paramsSerializer?: ParamsSerializer;
|
||||||
customFetch?: typeof fetch;
|
responseParser?: ResponseParser;
|
||||||
|
onRequest?: RequestInterceptor;
|
||||||
|
onResponse?: ResponseInterceptor;
|
||||||
|
onError?: ErrorInterceptor;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HttpResponse<D extends unknown, E extends unknown = unknown> extends Response {
|
export interface HttpResponse<D extends unknown, E extends unknown = unknown> extends Response {
|
||||||
data: D;
|
data: D;
|
||||||
error: E;
|
error: E;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError<E = unknown> 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 type CancelToken = Symbol | string | number;
|
||||||
|
|
||||||
export enum ContentType {
|
export enum ContentType {
|
||||||
Json = "application/json",
|
Json = "application/json",
|
||||||
JsonApi = "application/vnd.api+json",
|
JsonApi = "application/vnd.api+json",
|
||||||
FormData = "multipart/form-data",
|
FormData = "multipart/form-data",
|
||||||
UrlEncoded = "application/x-www-form-urlencoded",
|
UrlEncoded = "application/x-www-form-urlencoded",
|
||||||
Text = "text/plain",
|
Text = "text/plain",
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HttpClient<SecurityDataType = unknown> implements ApiRequestClient {
|
export class HttpClient<SecurityDataType = unknown> implements ApiRequestClient {
|
||||||
public baseUrl: string = "<%~ baseUrl %>";
|
public baseUrl: string = "<%~ baseUrl %>";
|
||||||
private securityData: SecurityDataType | null = null;
|
private abortControllers = new Map<CancelToken, AbortController>();
|
||||||
private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
|
private customFetch: typeof fetch = (...fetchParams) => fetch(...fetchParams);
|
||||||
private abortControllers = new Map<CancelToken, AbortController>();
|
private paramsSerializer?: ParamsSerializer;
|
||||||
private customFetch = (...fetchParams: Parameters<typeof fetch>) => fetch(...fetchParams);
|
private responseParser?: ResponseParser;
|
||||||
|
private onRequest?: RequestInterceptor;
|
||||||
|
private onResponse?: ResponseInterceptor;
|
||||||
|
private onError?: ErrorInterceptor;
|
||||||
|
|
||||||
private baseApiParams: RequestParams = {
|
private baseRequestParams: RequestParams = {
|
||||||
credentials: 'same-origin',
|
credentials: "same-origin",
|
||||||
headers: {},
|
headers: {},
|
||||||
redirect: 'follow',
|
redirect: "follow",
|
||||||
referrerPolicy: 'no-referrer',
|
referrerPolicy: "no-referrer",
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
baseUrl,
|
||||||
|
customFetch,
|
||||||
|
paramsSerializer,
|
||||||
|
responseParser,
|
||||||
|
onRequest,
|
||||||
|
onResponse,
|
||||||
|
onError,
|
||||||
|
...baseRequestParams
|
||||||
|
}: ApiConfig<SecurityDataType> = {}) {
|
||||||
|
if (typeof baseUrl === "string") {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(apiConfig: ApiConfig<SecurityDataType> = {}) {
|
this.customFetch = customFetch || this.customFetch;
|
||||||
Object.assign(this, apiConfig);
|
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) => {
|
const keys = Object.keys(query).filter((key) => "undefined" !== typeof query[key]);
|
||||||
this.securityData = data;
|
return keys
|
||||||
}
|
.map((key) =>
|
||||||
|
Array.isArray(query[key])
|
||||||
|
? this.addArrayQueryParam(query, key)
|
||||||
|
: this.addQueryParam(query, key),
|
||||||
|
)
|
||||||
|
.join("&");
|
||||||
|
}
|
||||||
|
|
||||||
protected encodeQueryParam(key: string, value: any) {
|
protected addQueryParams(rawQuery?: QueryParamsType): string {
|
||||||
const encodedKey = encodeURIComponent(key);
|
const queryString = this.toQueryString(rawQuery);
|
||||||
return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`;
|
return queryString ? `?${queryString}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
protected addQueryParam(query: QueryParamsType, key: string) {
|
protected buildRequestUrl(baseUrl: string | undefined, path: string, query?: QueryParamsType): string {
|
||||||
return this.encodeQueryParam(key, query[key]);
|
return `${baseUrl || this.baseUrl || ""}${path}${this.addQueryParams(query)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected addArrayQueryParam(query: QueryParamsType, key: string) {
|
protected createRequestContext<TResult>(
|
||||||
const value = query[key];
|
request: FullRequestParams,
|
||||||
return value.map((v: any) => this.encodeQueryParam(key, v)).join("&");
|
retryCount: number,
|
||||||
}
|
retry: () => Promise<TResult>,
|
||||||
|
): RequestContext<TResult> {
|
||||||
|
return {
|
||||||
|
url: this.buildRequestUrl(request.baseUrl, request.path, request.query),
|
||||||
|
request,
|
||||||
|
retryCount,
|
||||||
|
retry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
protected toQueryString(rawQuery?: QueryParamsType): string {
|
protected updateRequestContext<TResult>(context: RequestContext<TResult>, request: FullRequestParams) {
|
||||||
const query = rawQuery || {};
|
context.request = request;
|
||||||
const keys = Object.keys(query).filter((key) => "undefined" !== typeof query[key]);
|
context.url = this.buildRequestUrl(request.baseUrl, request.path, request.query);
|
||||||
return keys
|
}
|
||||||
.map((key) =>
|
|
||||||
Array.isArray(query[key])
|
|
||||||
? this.addArrayQueryParam(query, key)
|
|
||||||
: this.addQueryParam(query, key),
|
|
||||||
)
|
|
||||||
.join("&");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected addQueryParams(rawQuery?: QueryParamsType): string {
|
protected mergeHeaders(...headers: Array<HeadersInit | undefined>): HeadersInit {
|
||||||
const queryString = this.toQueryString(rawQuery);
|
const mergedHeaders = new Headers();
|
||||||
return queryString ? `?${queryString}` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private contentFormatters: Record<ContentType, (input: any) => any> = {
|
headers.forEach((headers) => {
|
||||||
[ContentType.Json]: (input:any) => input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input,
|
if (!headers) {
|
||||||
[ContentType.JsonApi]: (input:any) => input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input,
|
return;
|
||||||
[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) => {
|
new Headers(headers).forEach((value, key) => mergedHeaders.set(key, value));
|
||||||
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 mergeRequestParams(params1: RequestParams, params2?: RequestParams): RequestParams {
|
return Object.fromEntries(mergedHeaders.entries());
|
||||||
return {
|
}
|
||||||
...this.baseApiParams,
|
|
||||||
...params1,
|
|
||||||
...(params2 || {}),
|
|
||||||
headers: {
|
|
||||||
...(this.baseApiParams.headers || {}),
|
|
||||||
...(params1.headers || {}),
|
|
||||||
...((params2 && params2.headers) || {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
protected createAbortSignal = (cancelToken: CancelToken): AbortSignal | undefined => {
|
protected mergeRequestParams<T extends Partial<FullRequestParams>>(
|
||||||
if (this.abortControllers.has(cancelToken)) {
|
params1: T,
|
||||||
const abortController = this.abortControllers.get(cancelToken);
|
params2?: Partial<FullRequestParams>,
|
||||||
if (abortController) {
|
): T {
|
||||||
return abortController.signal;
|
return {
|
||||||
}
|
...params1,
|
||||||
return void 0;
|
...(params2 || {}),
|
||||||
}
|
headers: this.mergeHeaders(params1.headers, params2?.headers),
|
||||||
|
} as T;
|
||||||
|
}
|
||||||
|
|
||||||
const abortController = new AbortController();
|
protected createAbortSignal = (cancelToken: CancelToken): AbortSignal | undefined => {
|
||||||
this.abortControllers.set(cancelToken, abortController);
|
if (this.abortControllers.has(cancelToken)) {
|
||||||
|
const abortController = this.abortControllers.get(cancelToken);
|
||||||
|
if (abortController) {
|
||||||
return abortController.signal;
|
return abortController.signal;
|
||||||
|
}
|
||||||
|
return void 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public abortRequest = (cancelToken: CancelToken) => {
|
const abortController = new AbortController();
|
||||||
const abortController = this.abortControllers.get(cancelToken)
|
this.abortControllers.set(cancelToken, abortController);
|
||||||
|
return abortController.signal;
|
||||||
|
};
|
||||||
|
|
||||||
if (abortController) {
|
protected createRequestSignal = (
|
||||||
abortController.abort();
|
signal?: AbortSignal | null,
|
||||||
this.abortControllers.delete(cancelToken);
|
cancelToken?: CancelToken,
|
||||||
|
timeout?: number,
|
||||||
|
): { signal: AbortSignal | null; cleanup: () => void } => {
|
||||||
|
const signals: AbortSignal[] = [];
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | 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<ContentType, (input: any) => 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 <T = any, E = any>(response: Response, responseFormat?: ResponseFormat): Promise<HttpResponse<T, E>> => {
|
||||||
|
const parsedResponse = response as HttpResponse<T, E>;
|
||||||
|
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 <T = any, E = any>({
|
return parsedResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
public request = async <T = any, E = any>(requestParams: FullRequestParams) => {
|
||||||
|
return this.requestWithRetry<T, E>(requestParams, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
private requestWithRetry = async <T = any, E = any>(
|
||||||
|
requestParams: FullRequestParams,
|
||||||
|
retryCount: number,
|
||||||
|
<% if (config.unwrapResponseData) { %>
|
||||||
|
): Promise<T> => {
|
||||||
|
<% } else { %>
|
||||||
|
): Promise<HttpResponse<T, E>> => {
|
||||||
|
<% } %>
|
||||||
|
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<T, E>
|
||||||
|
<% } %>
|
||||||
|
>(
|
||||||
|
request,
|
||||||
|
retryCount,
|
||||||
|
() => this.requestWithRetry<T, E>(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,
|
body,
|
||||||
secure,
|
secure,
|
||||||
path,
|
path,
|
||||||
@@ -175,62 +419,54 @@ export class HttpClient<SecurityDataType = unknown> implements ApiRequestClient
|
|||||||
query,
|
query,
|
||||||
format,
|
format,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
cancelToken,
|
cancelToken: requestCancelToken,
|
||||||
|
timeout,
|
||||||
...params
|
...params
|
||||||
<% if (config.unwrapResponseData) { %>
|
} = request;
|
||||||
}: FullRequestParams): Promise<T> => {
|
|
||||||
<% } else { %>
|
|
||||||
}: FullRequestParams): Promise<HttpResponse<T, E>> => {
|
|
||||||
<% } %>
|
|
||||||
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;
|
|
||||||
|
|
||||||
return this.customFetch(
|
cancelToken = requestCancelToken;
|
||||||
`${baseUrl || this.baseUrl || ""}${path}${queryString ? `?${queryString}` : ""}`,
|
const { signal, cleanup } = this.createRequestSignal(params.signal, cancelToken, timeout);
|
||||||
{
|
cleanupSignal = cleanup;
|
||||||
...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<T, E>;
|
|
||||||
r.data = (null as unknown) as T;
|
|
||||||
r.error = (null as unknown) as E;
|
|
||||||
|
|
||||||
const responseToParse = responseFormat ? response.clone() : response;
|
const payloadFormatter = this.contentFormatters[type || ContentType.Json];
|
||||||
const data = !responseFormat ? r : await responseToParse[responseFormat]()
|
const responseFormat = format;
|
||||||
.then((data) => {
|
const response = await this.customFetch(context.url, {
|
||||||
if (r.ok) {
|
...params,
|
||||||
r.data = data;
|
headers: this.mergeHeaders(
|
||||||
} else {
|
params.headers,
|
||||||
r.error = data;
|
type && type !== ContentType.FormData ? { "Content-Type": type } : undefined,
|
||||||
}
|
),
|
||||||
return r;
|
signal,
|
||||||
})
|
body: typeof body === "undefined" || body === null ? null : payloadFormatter(body),
|
||||||
.catch((e) => {
|
});
|
||||||
r.error = e;
|
|
||||||
return r;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (cancelToken) {
|
const parsedResponse = await this.parseResponse<T, E>(response, responseFormat);
|
||||||
this.abortControllers.delete(cancelToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
<% if (!config.disableThrowOnError) { %>
|
<% if (!config.disableThrowOnError) { %>
|
||||||
if (!response.ok) throw data;
|
if (!parsedResponse.ok) {
|
||||||
|
throw new ApiError<E>(parsedResponse, request, parsedResponse.error || parsedResponse.data, parsedResponse.error);
|
||||||
|
}
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
|
const finalResponse = this.onResponse
|
||||||
|
? await this.onResponse<T, E>(parsedResponse, context)
|
||||||
|
: parsedResponse;
|
||||||
|
|
||||||
<% if (config.unwrapResponseData) { %>
|
<% if (config.unwrapResponseData) { %>
|
||||||
return data.data;
|
return finalResponse.data;
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
return data;
|
return finalResponse;
|
||||||
<% } %>
|
<% } %>
|
||||||
});
|
} catch (error) {
|
||||||
};
|
cleanupRequest();
|
||||||
|
|
||||||
|
if (this.onError) {
|
||||||
|
return this.onError(error, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
cleanupRequest();
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ bun test -t "should generate client with custom name"
|
|||||||
- ❌ Без outputPath
|
- ❌ Без outputPath
|
||||||
- ✅ Опциональное поле fileName
|
- ✅ Опциональное поле fileName
|
||||||
|
|
||||||
### 4. Сгенерированный клиент (7 кейсов)
|
### 4. Сгенерированный клиент
|
||||||
|
|
||||||
**Компиляция:**
|
**Компиляция:**
|
||||||
- ✅ TypeScript компиляция без ошибок
|
- ✅ TypeScript компиляция без ошибок
|
||||||
@@ -152,9 +152,10 @@ bun test -t "should generate client with custom name"
|
|||||||
- ✅ Все endpoints присутствуют
|
- ✅ Все endpoints присутствуют
|
||||||
- ✅ Корректные имена методов
|
- ✅ Корректные имена методов
|
||||||
- ✅ HttpClient инициализация
|
- ✅ HttpClient инициализация
|
||||||
- ✅ Метод `setSecurityData`
|
- ✅ Lifecycle hooks в `HttpClient`
|
||||||
|
- ✅ Отсутствие устаревшего `baseApiParams`
|
||||||
|
|
||||||
### 5. Интеграционные E2E (15 кейсов)
|
### 5. Интеграционные E2E
|
||||||
|
|
||||||
**Полный цикл:**
|
**Полный цикл:**
|
||||||
- ✅ CLI → создание → импорт → использование
|
- ✅ CLI → создание → импорт → использование
|
||||||
@@ -164,17 +165,15 @@ bun test -t "should generate client with custom name"
|
|||||||
|
|
||||||
**HTTP с mock:**
|
**HTTP с mock:**
|
||||||
- ✅ GET без параметров
|
- ✅ GET без параметров
|
||||||
- ✅ GET с query параметрами
|
|
||||||
- ✅ POST с body
|
- ✅ POST с body
|
||||||
- ✅ PUT/PATCH/DELETE
|
- ✅ Обработка 404 статуса
|
||||||
- ✅ Статусы 200, 201, 400, 401, 404, 500
|
- ✅ Bearer authentication через `onRequest`
|
||||||
- ❌ Network errors
|
- ✅ Retry через `onError` и `ApiError`
|
||||||
- ✅ Bearer authentication
|
- ✅ Timeout через `AbortSignal`
|
||||||
- ✅ Custom headers
|
|
||||||
|
|
||||||
|
|
||||||
## Дополнительная информация
|
## Дополнительная информация
|
||||||
|
|
||||||
- [План тестирования](../TESTING-PLAN.md)
|
- [План тестирования](../TESTING-PLAN.md)
|
||||||
- [Contributing Guidelines](../CONTRIBUTING.md)
|
- [Contributing Guidelines](../CONTRIBUTING.md)
|
||||||
- [Bun Test Documentation](https://bun.sh/docs/cli/test)
|
- [Bun Test Documentation](https://bun.sh/docs/cli/test)
|
||||||
|
|||||||
@@ -339,17 +339,23 @@ describe('E2E Generation', () => {
|
|||||||
const { login } = await import(join(outputPath, 'operations', 'login.ts'));
|
const { login } = await import(join(outputPath, 'operations', 'login.ts'));
|
||||||
const { get } = await import(join(outputPath, 'operations', 'get.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({
|
const httpClient = new HttpClient({
|
||||||
securityWorker: (securityData: string | null) => {
|
onRequest: (params) => {
|
||||||
if (securityData) {
|
if (!params.secure || !token) {
|
||||||
return {
|
return params;
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${securityData}`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
return {
|
||||||
|
...params,
|
||||||
|
headers: {
|
||||||
|
...params.headers,
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const api = createApiClient(httpClient, {
|
const api = createApiClient(httpClient, {
|
||||||
@@ -368,12 +374,100 @@ describe('E2E Generation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Установка токена
|
// Установка токена
|
||||||
httpClient.setSecurityData(loginResult.token);
|
token = loginResult.token;
|
||||||
|
|
||||||
// Запрос с токеном
|
// Запрос с токеном
|
||||||
const profile = await api.profile.get();
|
const profile = await api.profile.get();
|
||||||
|
|
||||||
expect(profile.email).toBe('test@example.com');
|
expect(profile.email).toBe('test@example.com');
|
||||||
}, 60000);
|
}, 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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ describe('Generated Client', () => {
|
|||||||
expect(content).toContain('https://api.example.com');
|
expect(content).toContain('https://api.example.com');
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
test('метод setSecurityData должен работать', async () => {
|
test('HttpClient должен поддерживать lifecycle hooks', async () => {
|
||||||
const outputPath = join(tempDir, 'output');
|
const outputPath = join(tempDir, 'output');
|
||||||
const config: GeneratorConfig = {
|
const config: GeneratorConfig = {
|
||||||
inputPath: FIXTURES.WITH_AUTH,
|
inputPath: FIXTURES.WITH_AUTH,
|
||||||
@@ -162,8 +162,12 @@ describe('Generated Client', () => {
|
|||||||
const generatedFile = join(outputPath, 'http-client.ts');
|
const generatedFile = join(outputPath, 'http-client.ts');
|
||||||
const content = await readTextFile(generatedFile);
|
const content = await readTextFile(generatedFile);
|
||||||
|
|
||||||
// Проверяем наличие метода для установки токена
|
// Проверяем наличие hooks для кастомизации запросов
|
||||||
expect(content).toContain('setSecurityData');
|
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);
|
}, 30000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ describe('Generator', () => {
|
|||||||
expect(content).toContain('guest');
|
expect(content).toContain('guest');
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
test('должен обработать Bearer authentication', async () => {
|
test('должен генерировать hooks для авторизации', async () => {
|
||||||
const outputPath = join(tempDir, 'output');
|
const outputPath = join(tempDir, 'output');
|
||||||
const config: GeneratorConfig = {
|
const config: GeneratorConfig = {
|
||||||
inputPath: FIXTURES.WITH_AUTH,
|
inputPath: FIXTURES.WITH_AUTH,
|
||||||
@@ -265,8 +265,11 @@ describe('Generator', () => {
|
|||||||
const generatedFile = join(outputPath, 'http-client.ts');
|
const generatedFile = join(outputPath, 'http-client.ts');
|
||||||
const content = await readTextFile(generatedFile);
|
const content = await readTextFile(generatedFile);
|
||||||
|
|
||||||
// Проверяем наличие методов для работы с токеном
|
// Проверяем наличие hooks для добавления авторизации
|
||||||
expect(content).toContain('setSecurityData');
|
expect(content).toContain('onRequest?: RequestInterceptor');
|
||||||
|
expect(content).toContain('secure?: boolean');
|
||||||
|
expect(content).not.toContain('baseApiParams');
|
||||||
|
expect(content).not.toContain('setSecurityData');
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
test('должен использовать baseUrl из servers', async () => {
|
test('должен использовать baseUrl из servers', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user