feat: переработать кастомизацию HttpClient
- добавлен плоский ApiConfig с lifecycle hooks - добавлены ApiError, retry context, timeout и кастомные parser/serializer - обновлены примеры, документация и тесты под новый API
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user