feat: переработать кастомизацию HttpClient

- добавлен плоский ApiConfig с lifecycle hooks
- добавлены ApiError, retry context, timeout и кастомные parser/serializer
- обновлены примеры, документация и тесты под новый API
This commit is contained in:
2026-06-30 23:52:06 +03:00
parent fe5d3ae091
commit 6662224a9a
9 changed files with 719 additions and 217 deletions

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,17 +165,15 @@ bun test -t "should generate client with custom name"
**HTTP с mock:**
- ✅ GET без параметров
- ✅ GET с query параметрами
- ✅ POST с body
-PUT/PATCH/DELETE
-Статусы 200, 201, 400, 401, 404, 500
- ❌ Network errors
-Bearer authentication
- ✅ Custom headers
-Обработка 404 статуса
-Bearer authentication через `onRequest`
- ✅ Retry через `onError` и `ApiError`
-Timeout через `AbortSignal`
## Дополнительная информация
- [План тестирования](../TESTING-PLAN.md)
- [Contributing Guidelines](../CONTRIBUTING.md)
- [Bun Test Documentation](https://bun.sh/docs/cli/test)
- [Bun Test Documentation](https://bun.sh/docs/cli/test)

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) {
return {
headers: {
Authorization: `Bearer ${securityData}`
}
};
onRequest: (params) => {
if (!params.secure || !token) {
return params;
}
}
return {
...params,
headers: {
...params.headers,
Authorization: `Bearer ${token}`,
},
};
},
});
const api = createApiClient(httpClient, {
@@ -368,12 +374,100 @@ describe('E2E Generation', () => {
});
// Установка токена
httpClient.setSecurityData(loginResult.token);
token = loginResult.token;
// Запрос с токеном
const profile = await api.profile.get();
expect(profile.email).toBe('test@example.com');
}, 60000);
test('onError должен поддерживать retry после ApiError', async () => {
const outputPath = join(tempDir, 'output');
await execa('bun', [
'run',
CLI_PATH,
'--input',
FIXTURES.MINIMAL,
'--output',
outputPath,
'--mode',
'split',
]);
const generatedFile = join(outputPath, 'index.ts');
const { ApiError, HttpClient } = await import(generatedFile);
let calls = 0;
const http = new HttpClient({
customFetch: async () => {
calls += 1;
if (calls === 1) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
return Response.json({ ok: true });
},
onError: (error, context) => {
if (error instanceof ApiError && error.status === 401 && context.retryCount === 0) {
return context.retry();
}
throw error;
},
});
const result = await http.request({
path: '/retry',
method: 'GET',
format: 'json',
});
expect(calls).toBe(2);
expect(result).toEqual({ ok: true });
}, 60000);
test('timeout должен отменять зависший запрос', async () => {
const outputPath = join(tempDir, 'output');
await execa('bun', [
'run',
CLI_PATH,
'--input',
FIXTURES.MINIMAL,
'--output',
outputPath,
'--mode',
'split',
]);
const generatedFile = join(outputPath, 'index.ts');
const { HttpClient } = await import(generatedFile);
const http = new HttpClient({
customFetch: (_url, init) => new Promise((resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
reject(new DOMException('The operation was aborted.', 'AbortError'));
});
}),
onError: (error) => {
if (error instanceof DOMException && error.name === 'AbortError') {
return { aborted: true };
}
throw error;
},
});
const result = await http.request({
path: '/timeout',
method: 'GET',
timeout: 1,
});
expect(result).toEqual({ aborted: true });
}, 60000);
});
});

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