Compare commits

..

5 Commits

Author SHA1 Message Date
75bd97e8f9 feat: добавить ручной runtime-клиент
- добавлен корневой runtime export HttpClient и createApiClient
- настроена сборка JS и d.ts для публичного API пакета
- добавлен тест ручной сборки operations через createApiClient
- добавлена документация по ручному клиенту без OpenAPI
- версия пакета поднята до 5.1.0
2026-07-03 10:51:56 +03:00
b8bb267848 chore: подготовить релиз 5.0.1
- поднята patch-версия после исправлений HttpClient
2026-07-01 00:13:36 +03:00
4ce5ea9b65 fix: исправить edge cases HttpClient
- ошибки парсинга успешных ответов проброшены в onError
- добавлена защита от перезаписи явного Authorization
- обновлены тесты, README и примеры кастомизации
2026-07-01 00:13:18 +03:00
886e20ef88 chore: подготовить релиз 5.0.0
- поднята major-версия пакета после breaking changes HttpClient
2026-06-30 23:52:25 +03:00
6662224a9a feat: переработать кастомизацию HttpClient
- добавлен плоский ApiConfig с lifecycle hooks
- добавлены ApiError, retry context, timeout и кастомные parser/serializer
- обновлены примеры, документация и тесты под новый API
2026-06-30 23:52:06 +03:00
16 changed files with 1537 additions and 225 deletions

View File

@@ -100,8 +100,26 @@ 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;
}
const headers = new Headers(params.headers);
if (!headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`);
}
return {
...params,
headers,
};
},
});
const api = createApiClient(httpClient, {
auth: {

251
README.md
View File

@@ -137,36 +137,197 @@ 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<SecurityData>({
const http = new HttpClient({
baseUrl: 'https://api.example.com',
baseApiParams: {
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;
}
const headers = new Headers(params.headers);
if (!headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`);
}
return {
headers: {
Authorization: `Bearer ${securityData.token}`,
...params,
headers,
};
},
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`, поэтому авторизацию можно добавлять только там, где она нужна.
`onError` должен либо бросить ошибку, либо вернуть fallback-значение, либо вернуть результат `context.retry()`. Если вернуть `undefined`, ошибка будет считаться обработанной, а вызывающий код получит `undefined` вместо исключения.
### Опции 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. Ошибки парсинга успешных ответов попадают в `onError`. |
| `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 сценариев. Должен вернуть fallback/retry или бросить ошибку. |
Также можно использовать другие стандартные поля `RequestInit`, доступные в вашей TypeScript/Runtime среде, если они не относятся к конкретному request body или cancellation lifecycle.
Эти поля не задаются на уровне конструктора и передаются конкретной operation или самому `request`:
| Поле | Почему не в конструкторе |
| --- | --- |
| `path` | Генерируется из OpenAPI path для конкретной operation. |
| `method` | Генерируется из OpenAPI HTTP метода. |
| `query` | Уникален для конкретного вызова operation. |
| `body` | Уникален для конкретного запроса. |
| `signal` | Один общий `AbortSignal` может случайно отменить группу независимых запросов. |
| `cancelToken` | Всегда относится к одному конкретному запросу. |
### Рецепты Кастомизации
Авторизация через `onRequest` без перезаписи явно переданного `Authorization`:
```typescript
const http = new HttpClient({
onRequest: (params) => {
const token = localStorage.getItem('access_token');
if (!params.secure || !token) {
return params;
}
const headers = new Headers(params.headers);
if (!headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`);
}
return {
...params,
headers,
};
},
});
```
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.
@@ -182,6 +343,66 @@ await api.users.getAll(
);
```
## Ручной Клиент Без OpenAPI
Если в проекте нет OpenAPI спецификации, можно использовать `HttpClient` напрямую из пакета и писать operation-функции вручную тем же стилем, который использует generated SDK.
В этом сценарии `@gromlab/api-codegen` должен быть зависимостью проекта:
```bash
bun add @gromlab/api-codegen
```
Пример ручной операции:
```typescript
import {
ContentType,
HttpClient,
createApiClient,
type ApiRequestClient,
type RequestParams,
} from '@gromlab/api-codegen';
type User = {
id: string;
email: string;
};
type CreateUserPayload = {
email: string;
};
export const createUser = (
http: ApiRequestClient,
body: CreateUserPayload,
requestParams: RequestParams = {},
) =>
http.request<User>({
path: '/users',
method: 'POST',
body,
type: ContentType.Json,
format: 'json',
secure: true,
...requestParams,
});
const http = new HttpClient({
baseUrl: 'https://api.example.com',
});
export const api = createApiClient(http, {
users: {
create: createUser,
},
});
const user = await api.users.create({ email: 'user@example.com' });
```
Ручной режим не связан с автогенерацией. Если вы запускаете `npx @gromlab/api-codegen` и генерируете REST SDK из OpenAPI, generated-код по-прежнему содержит и экспортирует собственные `HttpClient`, `createApiClient`, types и operations без runtime-зависимости от `@gromlab/api-codegen`.
## API Как npm-Пакет
Типичный workflow:

View File

@@ -21,6 +21,7 @@
"execa": "^8.0.0",
"msw": "^2.0.0",
"tmp": "^0.2.1",
"typescript": "^5",
},
"peerDependencies": {
"typescript": "^5",

View File

@@ -8,26 +8,27 @@ 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<SecurityData>({
const httpClient = new HttpClient({
baseUrl: 'https://api.example.com',
baseApiParams: {
headers: {
'Content-Type': 'application/json',
},
},
securityWorker: (securityData) => {
if (!securityData?.token) {
return undefined;
onRequest: (params) => {
if (!params.secure || !accessToken) {
return params;
}
const headers = new Headers(params.headers);
if (!headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${accessToken}`);
}
return {
headers: {
Authorization: `Bearer ${securityData.token}`,
},
...params,
headers,
};
},
});
@@ -58,7 +59,7 @@ async function loginUser() {
password: 'SecurePassword123',
});
httpClient.setSecurityData({ token: result.access_token });
accessToken = result.access_token;
return result;
}

View File

@@ -1,8 +1,17 @@
{
"name": "@gromlab/api-codegen",
"version": "4.0.0",
"description": "CLI tool to generate TypeScript API client from OpenAPI specification",
"version": "5.1.0",
"description": "CLI tool to generate TypeScript API client from OpenAPI specification and runtime HTTP client for manual APIs",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./package.json": "./package.json"
},
"bin": {
"api-codegen": "dist/cli.js"
},
@@ -17,7 +26,7 @@
"node": ">=18"
},
"scripts": {
"build": "bun build src/cli.ts --target=node --outdir=dist --format=esm --external=@biomejs/* && cp -r src/templates dist/",
"build": "bun build src/cli.ts --target=node --outdir=dist --format=esm --external=@biomejs/* && bun build src/index.ts --target=node --outfile=dist/index.js --format=esm && tsc -p tsconfig.build.json && cp -r src/templates dist/",
"dev": "bun run src/cli.ts",
"test": "bun test",
"test:unit": "bun test tests/unit",
@@ -43,7 +52,8 @@
"@types/tmp": "^0.2.6",
"execa": "^8.0.0",
"msw": "^2.0.0",
"tmp": "^0.2.1"
"tmp": "^0.2.1",
"typescript": "^5"
},
"peerDependencies": {
"typescript": "^5"

View File

@@ -0,0 +1,44 @@
import type { ApiRequestClient } from './http-client.js';
export type ApiOperation<TClient extends ApiRequestClient = ApiRequestClient> = (
client: TClient,
...args: any[]
) => any;
export type ApiTree<TClient extends ApiRequestClient = ApiRequestClient> = {
readonly [key: string]: ApiOperation<TClient> | ApiTree<TClient>;
};
export type BoundApi<TTree, TClient extends ApiRequestClient> = {
readonly [K in keyof TTree]: TTree[K] extends (
client: TClient,
...args: infer Args
) => infer Result
? (...args: Args) => Result
: TTree[K] extends ApiTree<TClient>
? BoundApi<TTree[K], TClient>
: never;
};
export const createApiClient = <
TClient extends ApiRequestClient,
const TTree extends ApiTree<TClient>,
>(
client: TClient,
tree: TTree,
): BoundApi<TTree, TClient> => {
const bindNode = (node: ApiOperation<TClient> | ApiTree<TClient>): unknown => {
if (typeof node === 'function') {
return (...args: unknown[]) => node(client, ...args);
}
return Object.fromEntries(
Object.entries(node).map(([key, value]) => [
key,
bindNode(value as ApiOperation<TClient> | ApiTree<TClient>),
]),
);
};
return bindNode(tree) as BoundApi<TTree, TClient>;
};

448
src/client/http-client.ts Normal file
View File

@@ -0,0 +1,448 @@
export type QueryParamsType = Record<string | number, any>;
export type ResponseFormat = keyof Omit<Body, 'body' | 'bodyUsed'>;
export interface FullRequestParams extends Omit<RequestInit, 'body'> {
secure?: boolean;
path: string;
type?: ContentType;
query?: QueryParamsType;
format?: ResponseFormat;
body?: unknown;
baseUrl?: string;
cancelToken?: CancelToken;
timeout?: number;
}
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 type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
export interface ApiRequestClient {
request<T = any, E = any>(params: FullRequestParams): Promise<T>;
}
export interface ApiConfig<SecurityDataType = unknown> extends Omit<RequestParams, 'baseUrl' | 'cancelToken' | 'signal'> {
baseUrl?: string;
customFetch?: FetchLike;
paramsSerializer?: ParamsSerializer;
responseParser?: ResponseParser;
onRequest?: RequestInterceptor;
onResponse?: ResponseInterceptor;
onError?: ErrorInterceptor;
}
export interface HttpResponse<D extends unknown, E extends unknown = unknown> extends Response {
data: D;
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 enum ContentType {
Json = 'application/json',
JsonApi = 'application/vnd.api+json',
FormData = 'multipart/form-data',
UrlEncoded = 'application/x-www-form-urlencoded',
Text = 'text/plain',
}
export class HttpClient<SecurityDataType = unknown> implements ApiRequestClient {
public baseUrl = '';
private abortControllers = new Map<CancelToken, AbortController>();
private customFetch: FetchLike = (...fetchParams) => fetch(...fetchParams);
private paramsSerializer?: ParamsSerializer;
private responseParser?: ResponseParser;
private onRequest?: RequestInterceptor;
private onResponse?: ResponseInterceptor;
private onError?: ErrorInterceptor;
private baseRequestParams: RequestParams = {
credentials: 'same-origin',
headers: {},
redirect: 'follow',
referrerPolicy: 'no-referrer',
};
constructor({
baseUrl,
customFetch,
paramsSerializer,
responseParser,
onRequest,
onResponse,
onError,
...baseRequestParams
}: ApiConfig<SecurityDataType> = {}) {
if (typeof baseUrl === 'string') {
this.baseUrl = baseUrl;
}
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);
}
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 addQueryParams(rawQuery?: QueryParamsType): string {
const queryString = this.toQueryString(rawQuery);
return queryString ? `?${queryString}` : '';
}
protected buildRequestUrl(baseUrl: string | undefined, path: string, query?: QueryParamsType): string {
return `${baseUrl || this.baseUrl || ''}${path}${this.addQueryParams(query)}`;
}
protected createRequestContext<TResult>(
request: FullRequestParams,
retryCount: number,
retry: () => Promise<TResult>,
): RequestContext<TResult> {
return {
url: this.buildRequestUrl(request.baseUrl, request.path, request.query),
request,
retryCount,
retry,
};
}
protected updateRequestContext<TResult>(context: RequestContext<TResult>, request: FullRequestParams) {
context.request = request;
context.url = this.buildRequestUrl(request.baseUrl, request.path, request.query);
}
protected mergeHeaders(...headers: Array<HeadersInit | undefined>): HeadersInit {
const mergedHeaders = new Headers();
headers.forEach((headers) => {
if (!headers) {
return;
}
new Headers(headers).forEach((value, key) => mergedHeaders.set(key, value));
});
return Object.fromEntries(mergedHeaders.entries());
}
protected mergeRequestParams<T extends Partial<FullRequestParams>>(
params1: T,
params2?: Partial<FullRequestParams>,
): T {
return {
...params1,
...(params2 || {}),
headers: this.mergeHeaders(params1.headers, params2?.headers),
} as T;
}
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;
}
const abortController = new AbortController();
this.abortControllers.set(cancelToken, abortController);
return abortController.signal;
};
protected createRequestSignal = (
signal?: AbortSignal | null,
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) => {
if (parsedResponse.ok) {
throw error;
}
parsedResponse.error = error as E;
});
return parsedResponse;
};
public request = async <T = any, E = any>(requestParams: FullRequestParams): Promise<T> => {
return this.requestWithRetry<T, E>(requestParams, 0);
};
private requestWithRetry = async <T = any, E = any>(
requestParams: FullRequestParams,
retryCount: number,
): Promise<T> => {
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<T>(
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,
path,
type,
query,
format,
baseUrl,
cancelToken: requestCancelToken,
timeout,
...params
} = request;
cancelToken = requestCancelToken;
const { signal, cleanup } = this.createRequestSignal(params.signal, cancelToken, timeout);
cleanupSignal = cleanup;
const payloadFormatter = this.contentFormatters[type || ContentType.Json];
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),
});
const parsedResponse = await this.parseResponse<T, E>(response, format);
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;
return finalResponse.data;
} catch (error) {
cleanupRequest();
if (this.onError) {
return this.onError(error, context);
}
throw error;
} finally {
cleanupRequest();
}
};
}

View File

@@ -542,8 +542,8 @@ export async function generate(config: GeneratorConfig): Promise<void> {
[
'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";',

20
src/index.ts Normal file
View File

@@ -0,0 +1,20 @@
export { createApiClient } from './client/create-api-client.js';
export type { ApiOperation, ApiTree, BoundApi } from './client/create-api-client.js';
export { ApiError, ContentType, HttpClient } from './client/http-client.js';
export type {
ApiConfig,
ApiRequestClient,
CancelToken,
ErrorInterceptor,
FetchLike,
FullRequestParams,
HttpResponse,
ParamsSerializer,
QueryParamsType,
RequestContext,
RequestInterceptor,
RequestParams,
ResponseFormat,
ResponseInterceptor,
ResponseParser,
} from './client/http-client.js';

View File

@@ -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<string | number, any>;
export type ResponseFormat = keyof Omit<Body, "body" | "bodyUsed">;
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;
/** request path */
path: string;
@@ -23,19 +23,53 @@ export interface FullRequestParams extends Omit<RequestInit, "body"> {
baseUrl?: string;
/** request cancellation token */
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 {
<% 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;
baseApiParams?: Omit<RequestParams, "baseUrl" | "cancelToken" | "signal">;
securityWorker?: (securityData: SecurityDataType | null) => Promise<RequestParams | void> | RequestParams | void;
customFetch?: typeof fetch;
paramsSerializer?: ParamsSerializer;
responseParser?: ResponseParser;
onRequest?: RequestInterceptor;
onResponse?: ResponseInterceptor;
onError?: ErrorInterceptor;
}
export interface HttpResponse<D extends unknown, E extends unknown = unknown> extends Response {
@@ -43,6 +77,26 @@ export interface HttpResponse<D extends unknown, E extends unknown = unknown> ex
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 enum ContentType {
@@ -55,24 +109,42 @@ export enum ContentType {
export class HttpClient<SecurityDataType = unknown> implements ApiRequestClient {
public baseUrl: string = "<%~ baseUrl %>";
private securityData: SecurityDataType | null = null;
private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
private abortControllers = new Map<CancelToken, AbortController>();
private customFetch = (...fetchParams: Parameters<typeof fetch>) => fetch(...fetchParams);
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',
private baseRequestParams: RequestParams = {
credentials: "same-origin",
headers: {},
redirect: 'follow',
referrerPolicy: 'no-referrer',
redirect: "follow",
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> = {}) {
Object.assign(this, apiConfig);
}
public setSecurityData = (data: SecurityDataType | null) => {
this.securityData = data;
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) {
@@ -91,6 +163,11 @@ export class HttpClient<SecurityDataType = unknown> implements ApiRequestClient
protected toQueryString(rawQuery?: QueryParamsType): string {
const query = rawQuery || {};
if (this.paramsSerializer) {
return this.paramsSerializer(query);
}
const keys = Object.keys(query).filter((key) => "undefined" !== typeof query[key]);
return keys
.map((key) =>
@@ -106,42 +183,51 @@ export class HttpClient<SecurityDataType = unknown> implements ApiRequestClient
return queryString ? `?${queryString}` : "";
}
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;
protected buildRequestUrl(baseUrl: string | undefined, path: string, query?: QueryParamsType): string {
return `${baseUrl || this.baseUrl || ""}${path}${this.addQueryParams(query)}`;
}
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 mergeRequestParams(params1: RequestParams, params2?: RequestParams): RequestParams {
protected createRequestContext<TResult>(
request: FullRequestParams,
retryCount: number,
retry: () => Promise<TResult>,
): RequestContext<TResult> {
return {
url: this.buildRequestUrl(request.baseUrl, request.path, request.query),
request,
retryCount,
retry,
};
}
protected updateRequestContext<TResult>(context: RequestContext<TResult>, request: FullRequestParams) {
context.request = request;
context.url = this.buildRequestUrl(request.baseUrl, request.path, request.query);
}
protected mergeHeaders(...headers: Array<HeadersInit | undefined>): HeadersInit {
const mergedHeaders = new Headers();
headers.forEach((headers) => {
if (!headers) {
return;
}
new Headers(headers).forEach((value, key) => mergedHeaders.set(key, value));
});
return Object.fromEntries(mergedHeaders.entries());
}
protected mergeRequestParams<T extends Partial<FullRequestParams>>(
params1: T,
params2?: Partial<FullRequestParams>,
): T {
return {
...this.baseApiParams,
...params1,
...(params2 || {}),
headers: {
...(this.baseApiParams.headers || {}),
...(params1.headers || {}),
...((params2 && params2.headers) || {}),
},
};
headers: this.mergeHeaders(params1.headers, params2?.headers),
} as T;
}
protected createAbortSignal = (cancelToken: CancelToken): AbortSignal | undefined => {
@@ -156,18 +242,180 @@ export class HttpClient<SecurityDataType = unknown> implements ApiRequestClient
const abortController = new AbortController();
this.abortControllers.set(cancelToken, abortController);
return abortController.signal;
};
protected createRequestSignal = (
signal?: AbortSignal | null,
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)
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;
}
public request = async <T = any, E = any>({
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) => {
if (parsedResponse.ok) {
throw error;
}
parsedResponse.error = error as E;
});
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,
secure,
path,
@@ -175,62 +423,54 @@ export class HttpClient<SecurityDataType = unknown> implements ApiRequestClient
query,
format,
baseUrl,
cancelToken,
cancelToken: requestCancelToken,
timeout,
...params
<% if (config.unwrapResponseData) { %>
}: 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);
} = request;
cancelToken = requestCancelToken;
const { signal, cleanup } = this.createRequestSignal(params.signal, cancelToken, timeout);
cleanupSignal = cleanup;
const payloadFormatter = this.contentFormatters[type || ContentType.Json];
const responseFormat = format || requestParams.format;
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,
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),
}
).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 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;
});
if (cancelToken) {
this.abortControllers.delete(cancelToken);
}
const parsedResponse = await this.parseResponse<T, E>(response, responseFormat);
<% 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) { %>
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();
}
};
}

View File

@@ -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,13 +165,13 @@ 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`
- ✅ Сохранение явно переданного `Authorization`
-Retry через `onError` и `ApiError`
-Ошибки парсинга успешного ответа в `onError`
- ✅ Timeout через `AbortSignal`
## Дополнительная информация

View File

@@ -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) {
onRequest: (params) => {
if (!params.secure || !token) {
return params;
}
return {
...params,
headers: {
Authorization: `Bearer ${securityData}`
}
...params.headers,
Authorization: `Bearer ${token}`,
},
};
}
}
},
});
const api = createApiClient(httpClient, {
@@ -368,12 +374,196 @@ 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('onRequest не должен перетирать явно переданный Authorization', 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);
let authorizationHeader: string | null = null;
const http = new HttpClient({
customFetch: async (_url, init) => {
authorizationHeader = new Headers(init?.headers).get('Authorization');
return Response.json({ ok: true });
},
onRequest: (params) => {
if (!params.secure) {
return params;
}
const headers = new Headers(params.headers);
if (!headers.has('Authorization')) {
headers.set('Authorization', 'Bearer default-token');
}
return {
...params,
headers,
};
},
});
const result = await http.request({
path: '/auth-header',
method: 'GET',
secure: true,
format: 'json',
headers: {
Authorization: 'Bearer explicit-token',
},
});
expect(result).toEqual({ ok: true });
expect(authorizationHeader).toBe('Bearer explicit-token');
}, 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('onError должен получать ошибки парсинга успешного ответа', 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);
let handledError: unknown;
const http = new HttpClient({
customFetch: async () => new Response('{ invalid json', {
status: 200,
headers: {
'Content-Type': 'application/json',
},
}),
onError: (error) => {
handledError = error;
return { parsed: false };
},
});
const result = await http.request({
path: '/broken-json',
method: 'GET',
format: 'json',
});
expect(result).toEqual({ parsed: false });
expect(handledError).toBeInstanceOf(Error);
}, 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);
});
});

View File

@@ -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);
});

View File

@@ -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 () => {

View File

@@ -0,0 +1,97 @@
import { describe, expect, test } from 'bun:test';
import {
ContentType,
HttpClient,
createApiClient,
type ApiRequestClient,
type RequestParams,
} from '../../src/index.js';
describe('Manual runtime client', () => {
test('должен собирать ручные операции через createApiClient', async () => {
type User = {
id: string;
email: string;
};
type CreateUserQuery = {
invite?: boolean;
};
type CreateUserPayload = {
email: string;
};
const createUser = (
http: ApiRequestClient,
query: CreateUserQuery,
body: CreateUserPayload,
requestParams: RequestParams = {},
) =>
http.request<User>({
path: '/users',
method: 'POST',
query,
body,
type: ContentType.Json,
format: 'json',
secure: true,
...requestParams,
});
let requestUrl = '';
let requestInit: RequestInit | undefined;
const http = new HttpClient({
baseUrl: 'https://api.example.com',
customFetch: async (url, init) => {
requestUrl = String(url);
requestInit = init;
return Response.json({ id: '1', email: 'user@example.com' });
},
onRequest: (params) => {
if (!params.secure) {
return params;
}
const headers = new Headers(params.headers);
if (!headers.has('Authorization')) {
headers.set('Authorization', 'Bearer manual-token');
}
return {
...params,
headers,
};
},
});
const api = createApiClient(http, {
users: {
create: createUser,
},
});
const user = await api.users.create(
{ invite: true },
{ email: 'user@example.com' },
{
headers: {
'X-Request-Id': 'request-1',
},
},
);
const headers = new Headers(requestInit?.headers);
expect(user).toEqual({ id: '1', email: 'user@example.com' });
expect(requestUrl).toBe('https://api.example.com/users?invite=true');
expect(requestInit?.method).toBe('POST');
expect(requestInit?.body).toBe(JSON.stringify({ email: 'user@example.com' }));
expect(headers.get('Authorization')).toBe('Bearer manual-token');
expect(headers.get('Content-Type')).toBe(ContentType.Json);
expect(headers.get('X-Request-Id')).toBe('request-1');
});
});

14
tsconfig.build.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowJs": false,
"declaration": true,
"declarationMap": false,
"emitDeclarationOnly": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noEmit": false,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/index.ts", "src/client/**/*.ts"]
}